Skip to content

Harden macro runtime truth and execution lifecycle - #14

Merged
worflor merged 12 commits into
mainfrom
macros/runtime-truth
Sep 19, 2026
Merged

worflor merged 12 commits into
mainfrom
macros/runtime-truth

Conversation

@worflor

@worflor worflor commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

What changed

Make the Python macro boundary tell the truth about what is live, durable, queued, deleted, and merely being tested.

  • keep last-known-good callables/files when a replacement fails during module load
  • treat register/check/parse timeouts as control-plane failure and retire the exact alive-but-deaf sidecar
  • split ephemeral source execution from durable registration for Workshop Test, Test asks, beacon preview, and macro run --file
  • make Delete evict disk + durable manifest + warm registry immediately and retire that macro's worker/queue
  • reject lossy macro-name aliases instead of mapping distinct runtime ids onto one filename
  • stage registration invisibly, atomically persist source/options, and publish the live revision only through an explicit prepare/commit protocol
  • attach monotonic generations to registered revisions so queued fires cannot silently execute newer hot-reloaded code
  • add process-wide fire/worker ceilings on top of the existing per-macro queue cap
  • replace the global macro-state I/O lock with per-macro locks so unrelated state namespaces do not head-of-line block each other
  • document the intentional invoke(wait=True) subroutine semantics instead of implying it participates in the target's serial actor queue
  • extend real bundled-CPython e2e coverage for hung registration recovery, last-known-good behavior, ephemeral tests, live delete, canonical ids, and generation boundaries

The input hot path remains non-blocking. It gains only in-memory generation lookup plus the existing framed write; disk, policy parsing, spawning, and waits stay off dispatch.

Important semantics

invoke(wait=True) intentionally remains inline subroutine semantics and can overlap a separately-fired instance of the same target. wait=False is the form that enters the target's serial fire queue. This PR makes that exception explicit rather than hiding it behind the broader per-macro ordering claim.

Verification

  • Windows: .\validate.ps1 passed on the final branch, including bundled-CPython end-to-end and macro stress tests.
  • The registration publication fix was rerun through the full local quick gate after the edit.
  • GitHub Actions cannot start the what changed runner: its annotation says recent account payments failed or the spending limit must be increased. The Windows and Linux jobs are skipped before tests run.
  • No live hardware or input injection was exercised. This PR does not intentionally add device-write behavior.

Before this can merge

If the pull request contains work by more than one author, every author must
record the same acceptance in the pull request, unless the submitter has
authority to accept for them.

Registration used to mutate the registry as it compiled: if an edit raised at
module load time, the old callable was removed before the failure reached the
UI. Compile candidates off to the side and swap them in only after they are
complete, so a bad replacement leaves the last-known-good macro running.

Source tests now have a separate bounded execution path that never enters the
registry at all. That gives the GUI and CLI somewhere honest to run unsaved
source without persisting it, while four disposable slots put a ceiling on
deliberately hung test candidates.

Unregister also retires the per-id fire worker and discards its queued work.
Deleting and recreating macros no longer leaves one daemon thread per old id
for the rest of the sidecar lifetime.
A timed-out register/check/parse means the Python control loop stopped servicing
requests, not merely that one user macro is slow. Retire that exact session on a
control-plane timeout or broken pipe so an alive-but-deaf sidecar cannot keep
accepting work forever.

Registration is now last-known-good: validate the id, load the candidate into the
sidecar, persist only after its ack, then promote the durable manifest and option
schema. Failed candidates no longer replace disk or manifest state.

Add a fire_source protocol client for editor and ad-hoc CLI runs. It executes
source without adding it to the registry or touching macros/scripts, including a
mock-fire form for ask previews.

Macro names also stop passing through a lossy filename rewrite at creation: new
registrations accept only the exact filesystem-safe alphabet, so two runtime ids
can no longer silently share one script path. Deletion now evicts the warm
registry immediately instead of relying on a later process restart.
The macro editor's Test and Test asks controls called the durable register path.
Testing unsaved text therefore overwrote macros/scripts, and an unnamed test could
leave a real draft file behind. Route both controls through the new fire_source
candidate path instead: the exact editor text runs, but neither disk nor the live
registry changes.

The SYSTEM beacon preview now follows the same rule for saved source, and the
delete copy matches the corrected core behavior: deletion evicts the warm registry
immediately rather than waiting for a future sidecar restart.
Running a Python file was implemented as register-then-invoke, so a one-shot CLI
command silently copied that file into macros/scripts and could replace a live macro
whose id matched the filename. A run command should run; macro add is the command that
persists.

Use the non-persistent source execution path for --file while named registered macros
continue through the normal warm registry. The long beacon-friendly wait budget is
unchanged.
Extend the real-sidecar e2e proof over the three state transitions that previously
lied to callers: a bad replacement must leave both the old callable and old file
intact, source testing must execute without creating a script, and deleting a macro
must make it uncallable in the already-warm process.

These are end-to-end assertions against bundled CPython rather than mocks, because
the bugs all lived in the Rust/Python registry boundary.
A top-level infinite loop during registration leaves the CPython process alive, so
EOF/crash detection cannot identify the failure. Drive that exact case against the
real sidecar: registration must return on its control-plane deadline, the candidate
must never reach disk, and the next invocation must run in a newly spawned process.

This complements the existing external-kill recovery proof with the hang shape that
actually motivated retiring timed-out control sessions.
A successful Python compile used to replace the live callable before its source
was committed to disk. If the write then failed, a trigger could observe a
revision that the durable manifest still denied existed. Reverse that order
without sacrificing warm registration: prepare the namespace invisibly, write
the source atomically, record its generation, then commit the prepared callable.

Every durable macro now carries a monotonic generation into each fire. A press
queued under revision N therefore cannot wake up after a hot reload and execute
revision N+1; the sidecar refuses the stale fire instead of silently changing
what the trigger meant.

Macro source and option writes use the repo's atomic-write primitive, directory
scans ignore filenames that are not canonical macro ids, deletes are idempotent
and serialized with saves, and failed final publication rebuilds the exact
session from durable truth before reporting success.

The input path still performs only an in-memory generation lookup plus the
existing framed write; no disk or blocking work moved onto dispatch.
The per-macro queue cap stopped one hot trigger from growing forever, but it
was not a process bound: generated ids could each allocate a worker and their
own 256-item queue. Add global budgets for queued/running fires and live macro
workers. Refusal stays non-blocking and explicit, and unregister returns every
slot for discarded work before retiring that id's worker.

Persistent macro state had the inverse problem: four unrelated macros writing
four unrelated JSON files all serialized behind one process-global lock, so an
AV sharing retry in one namespace stalled the other three. Keep the required
load-mutate-atomic-replace exclusion per macro id instead. Unregister also
retires the keyed lock so churn does not monotonically grow the lock table.

The existing store stress still waits on real completion signals rather than
timing assumptions; its concurrency comments now describe the per-namespace
contract it actually proves.
Drive the generation boundary against the real bundled sidecar: start one old
revision, queue another fire behind it, publish the replacement, then prove the
queued old fire is refused while new presses reach the new callable. Also pin
that a manually dropped .py whose stem is not a canonical MacroId never enters
the runtime scan.

Clarify the deliberate exception to per-macro actor ordering: invoke(wait=True)
is inline subroutine semantics and can overlap a separately-fired target;
wait=False is the form that enters the target's serial queue.

Do not eagerly delete a per-macro state lock on unregister. A callable already
in flight can still be finishing a state operation after its registry entry is
removed; replacing its lock object in that window would create two independent
critical sections for one JSON file. Keep the tiny lock entry for process
lifetime instead of weakening the load-mutate-replace invariant.
@worflor worflor changed the title Harden macro runtime state and candidate execution Harden macro runtime truth and execution lifecycle Sep 19, 2026
@worflor
worflor marked this pull request as ready for review September 19, 2026 22:18
@worflor
worflor merged commit 91938ce into main Sep 19, 2026
2 of 3 checks passed
@worflor
worflor deleted the macros/runtime-truth branch September 19, 2026 22:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant