Skip to content

feat(blender): exec command for author-time bpy code, literal inputs, live per-frame progress (#692) - #696

Merged
pawellisowski merged 17 commits into
mainfrom
feat/692-blender-exec
Oct 12, 2026
Merged

pawellisowski merged 17 commits into
mainfrom
feat/692-blender-exec

Conversation

@pawellisowski

Copy link
Copy Markdown
Contributor

Summary

  • blender exec: the builtin blender agent (0.1.0 → 0.2.0) gains an exec command. An AI writes bpy code once, at authoring time, and it is approved into the .lock. AWARE then runs it headlessly and deterministically, the same way Tekla, Revit, Rhino and SketchUp exec work. This lets FloLess users ask from chat for building animations: a construction sequence, a fly-through, a sun study or an exploded view.
  • Literal inputs: a manifest input can be marked literal: true. A template there is refused at validate, compile and run with E_APP_INPUT_MUST_BE_LITERAL. That includes a whole-node template on inputs: or config:, so code supplied after approval can never run.
  • Live progress: frames are counted on render_write (EEVEE and Cycles give exactly N on Blender 5.2) and relayed through the feat(runtime): progressive large-geometry delivery for first render #405 node-progress channel and AWARE_PROGRESS_FILE. FloLess shows "Rendering frame N of M" so a long render never looks stuck.
  • Each render lands its own file: every render runs in a private scratch directory. Only a file of the requested format is moved onto the requested path, and it is verified to still be that file after the move.
  • Hardening: sys.dont_write_bytecode stops __pycache__ poisoning the immutable agent store (E_AGENT_STORE_INVALID). There is a per-call progress budget. The process tree is killed on timeout and drop.

Closes #692. Example app: 30-apps/_examples/model-to-construction-sequence.app. Skill: 20-agents/aeco/visualization/blender/skills/exec-bpy.md. Spec: 10-core/app-spec.md "Literal inputs".

Type of change

  • New skill (or skill modification)
  • App (under 30-apps/)
  • Spec / decalog / manifesto / docs
  • Bug fix in substrate
  • Other: new command on an existing builtin agent (blender 0.2.0)

Decalog check

  • This change respects all five decalog truths. The AI authors at plan time and AWARE runs the approved artifact; no model runs at run time.

Verification

  • cargo fmt --check, cargo clippy --all-targets -D warnings, and cargo test --no-fail-fast all pass: 2748 tests, 0 failed. Run locally on Windows.
  • Real Blender 5.2: test_exec.py, test_exec_render_output.py (concurrent writer, frame-suffix, nothing written, a side file of another type, the target swapped after the move, no scratch left) and test_exec_ifc_scene.py all pass.
  • A real run through FloLess rendered an 80–120 frame construction sequence from an IFC with live per-frame progress. The recompile succeeded with no __pycache__ in the stored package.

Notes for reviewers

  • Registry: registry-index.json still offers blender@0.1.0 only, now pinned to a commit. Publishing 0.2.0 is a separate follow-up PR, pinned to this PR's squash commit. Until then, model-to-renders.app and model-to-construction-sequence.app (requires: blender@0.2.x) need a local install of the agent.
  • Ctrl-C: Blender stays in aware's foreground process group, so a terminal Ctrl-C reaches it directly. Timeout and drop kill the tree by walking it: pgrep -P on Unix, taskkill /T /F on Windows. A process-wide watcher was tried and reverted after review on the operator's ruling. Full job-object hardening is tracked in Ctrl-C: kill Blender's whole process tree reliably (job object / group) — follow-up to #692 #695.
  • Not verified: the Unix tree-walk branch was never executed, because there is no Unix host here. It was only exercised as argv plus a simulated walk. Counted progress on Blender 4.2 is unmeasured: counting runs only on the measured allow-list {5.2}, and other versions report phase progress without a frame fraction.
  • Review: Codex ran 6 rounds and pr-review-toolkit ran once. The output-landing piece was rebuilt on the operator's ruling (scratch directory instead of snapshots), then patched once. The Ctrl-C piece was simplified on the operator's ruling.

🤖 Generated with Claude Code

…and run (#692)

An exec command's `code` is a program, and the approved `.lock` hashes the app
source, not what a template resolves to later. `code: '{{ inputs.code }}'`
would run Python supplied after a person approved the lock.

A manifest input may now declare `literal: true`. A node giving one any
template syntax ({{ }}, {% %}, {# #}) is refused with
E_APP_INPUT_MUST_BE_LITERAL naming the node and the input: by `app validate`
and the lock compiler (both through check_node_agents), and by the
orchestrator, which re-checks the node's merged UN-rendered value ahead of
every render_config, so a lock from an older compiler cannot smuggle one in
either. Templated non-literal inputs (exec `args`, i.e. data) still pass.

Only blender's exec.code adopts it here; tekla/rhino-8/revit-2026/
sketchup-2026 exec are #693.
…ansport (#692)

Long Blender renders were silent until they finished: node-progress (#405)
was relayed only for CLI transports, so a 40-minute animation looked exactly
like a hang.

The bounded Relay pump (8 KiB/record, 10,000 records, 16 MiB read,
progress-suppressed) moves from invoker.rs into runtime::progress as a shared
component. The CLI transport drives it beside wait_with_output as before;
render::blender drives it from INSIDE its own select loop, next to the
concurrent pipe drains, the deadline and the taskkill /T tree kill, with a
final drain after exit and after a timeout kill. The Blender runner is not
replaced by relay_progress, which would have lost both.

On a run, DispatchInvoker hands blender a run-owned channel
(AWARE_PROGRESS_FILE) and the records become node-progress events exactly as
for a CLI bridge. `aware agent invoke` passes a caller-set
AWARE_PROGRESS_FILE through to Blender (FloLess's Render card tails it); any
other launch removes the variable so an inherited one is never written to.

`exec` joins the command table (exec.py, 1800 s default), with pre-spawn
validation (non-empty string code, ifc-path/blend-path exclusive, output-dir
present, object args) failing as invalid-inputs, exit 3, and a dry-run that
echoes the inputs with the code's size, never the code.
… progress (agent 0.2.0, #692)

FloLess users want animations of their own model -- construction sequences,
fly-throughs, sun studies -- which the five fixed recipes cannot make. exec
follows the tekla/rhino exec model: the terminal AI writes bpy once at
authoring time, the code is pinned in the approved lock (`code` is
literal: true), and AWARE runs it unchanged on every run.

- manifest 0.1.0 -> 0.2.0: `exec` is mode: write and NOT mode-overridable
  (a read-mode node would run for real under --dry-run); outputs are
  {result, outputs, frames}; exec-bpy.md joins the skills.
- exec.py compiles the code first (a syntax error fails before any scene
  work), prepares one of three starting scenes (ifc-path: factory-empty +
  import + preset look + lighting; blend-path: opened as-is; neither:
  factory-empty), then calls run(ctx). Exceptions are exec-error with the
  line and an authored-only traceback; a result over 256 KiB is
  result-too-large.
- _exec_ctx.py: args, output_dir, scene, elements(), frame(), apply_look(),
  environment(), ground(), progress(), plan_passes(), render_still(),
  render_animation(), add_output(). Outputs are an explicit receipt: each
  entry verified (regular, non-empty, real path inside output_dir) or listed
  verified:false with the reason -- no directory diffing.
- _progress.py: one bounded line per record, a no-op without the channel.
  Frames are counted only on render_write (measured once per written frame
  on 5.2; frame_change_post overcounts) and only on measured versions
  {(5,2)}; elsewhere phases only. Each render call is its own segment
  labelled "pass i of n"; >5,000 frames coalesce. render.still and
  render.turntable report through it too.
- 30-apps/_examples/model-to-construction-sequence.app with a safety block.
- Registry: blender 0.1.0 frozen at c1b293f (its bytes, digest unchanged)
  so main can move; publishing 0.2.0 is a follow-up once this lands. The
  example and plan-doc pins move to blender@0.2.x; stats synced.
- Tests: Python under real Blender 5.2 (test_exec.py, test_exec_ifc_scene.py)
  and cli/tests/blender_exec.rs (validate/compile refuse templated code;
  --dry-run never launches a stand-in AWARE_BLENDER).
… call (#692 review)

Three ways the builtin runner could leave work running or report late:

- Dropping the call future (a failed trace write returning early through `?`,
  a cancelled run) left headless Blender rendering for up to timeout-seconds.
  The child is now kill_on_drop, spawned as a process-group leader on unix,
  and a TreeKillGuard (declared after the child, so it drops first) runs
  taskkill /T /F or kill -KILL -<pgid> unless the child was already reaped.
- The deadline was not checked while relay.pump() was blocked on a full
  channel (slow trace writer), so the call could run past its budget. The
  pump now runs under timeout_at(deadline), and the drain after a timeout kill
  is bounded to 2 s so a stopped consumer cannot hold the timeout error.
- The relay byte-budget test sampled `consumed` only when pump() returned
  true, missing the read of the pump that crossed the budget, so it could not
  catch an overrun. Relay keeps the last read count; the test asserts the
  real bound (budget plus one poll) and goes red when the budget check is
  removed.

Each fix has a test that fails without it (verified by reverting the fix).
…and errors stay honest (#692 review)

- Blocker: importing the helpers wrote scripts/__pycache__ INTO the immutable
  stored package (#626), so the next `aware app compile` refused it with
  E_AGENT_STORE_INVALID. Every entry script now sets
  sys.dont_write_bytecode = True before any helper import (Blender honours
  it; no launch flags change). test_package_stays_immutable.py checks it
  statically and by launching every command from a scratch package copy.
- Progress budget: coalescing was per pass but the runtime's 10,000-record
  cap is per invocation, so three 4,000-frame passes hit progress-suppressed
  in pass 3. _progress keeps a per-invocation budget of 9,000 records and
  each pass sizes its stride from what is left; every pass still reports its
  last frame.
- render_animation/render_still verify only a file written by THIS render
  (mtime at or after its start), in both the exact-name and frame-suffixed
  branches, so an earlier run's file at the target is never passed off.
- A NaN/Infinity result is exec-error (json allow_nan=False), not an
  unparseable frame the transport reports as its own failure.
- A corrupt .blend is blend-unreadable, not unexpected-error.
…e kill (#692)

Under a loaded full-suite run, taskkill could end the stand-in's ping a moment
before cmd.exe, which then ran its next line and wrote a second progress record
before dying, failing the timeout test's exact-count assertion. A wedged Blender
never proceeds, so the stand-in now exits after its hang.
…692 review 2)

The Blender runner cancels Relay::pump at its deadline. pump() advanced the
file cursor, then awaited one send per record, so a cancellation mid-send
dropped every record read but not yet sent, and the final drain started
past them.

Records now move from the file into a `pending` queue and leave it only after
a channel slot is reserved (Sender::reserve, then permit.send), so the next
pump or the final drain delivers what a cancelled pump held, oldest first.
The progress-suppressed notice queues behind the accepted records the same
way, and the 10,000-record budget counts records accepted, not sent.

Test a_pump_cancelled_mid_send_loses_and_repeats_nothing (capacity-1
channel, pump cancelled after one hand-off, then a final drain) failed with
[0] before the change and now delivers [0, 1, 2, 3, 4].
…ared helper (#692 review 2)

On Unix the timeout path called kill_blender, which killed Blender's pid
alone, and then disarmed the process-group guard, so a child process that
authored code spawned survived the timeout.

There is now one tree kill, kill_tree (taskkill /T /F on Windows,
kill -KILL -<pgid> elsewhere, built by tree_kill_command), used by both the
drop guard and the timeout path. kill_blender takes the guard's pid, which
also disarms it, and kills the tree before reaping the child.

Tests: the_timeout_path_kills_the_whole_tree_through_the_shared_helper
records which kill the timeout used (it failed with no tree kill before the
change) and requires a ticking grandchild to stop;
the_tree_kill_targets_the_tree_on_both_platforms pins both command shapes
on any platform. The Unix branch itself was not executed here (no Unix host).
…mp window (#692 review 2)

The 1-second mtime allowance still accepted a target an earlier run wrote
less than a second before the render, when Blender wrote a frame-suffixed
MP4 beside it, so the receipt verified the old file.

render_still and render_animation now snapshot every candidate file (the
target and any <stem>*.mp4 beside it) as (size, mtime_ns, file id) before
rendering, and afterwards accept only files created or changed since. If the
render wrote nothing at the target, the entry is verified:false, "was not
written by this render".

New test (a stale target written moments before the render, no utime)
failed before the change with the stale `seq` verified, and now names the
new seq*.mp4.
…h dir (#692 review 3)

A before/after snapshot proves a file changed, not that Blender changed it:
with two writers in one output-dir, another one creating the exact target
while this render wrote a frame-suffixed MP4 got its file reported as this
render's verified video. Rebuilt rather than patched, per the operator.

render_still / render_animation now render into a scratch directory that only
that call uses (tempfile.mkdtemp beside the requested path, so the final move
is a same-volume rename), with Blender's filepath inside it and no extension,
so Blender names the file however it likes. Afterwards the only candidates
are the files in that directory (several: the largest wins, documented). The
winner is os.replace'd onto the requested path, overwriting what is there by
design, and the scratch directory is removed in a finally. A render that
wrote nothing gets verified:false, "the render wrote no file". The
snapshot/mtime machinery is gone entirely, and so are the stale-target tests
that asserted the old suffix-beside behaviour.

test_exec_render_output.py, against real Blender: (a) a concurrent writer
creating the exact target mid-render (failed before: the receipt held the
other writer's bytes); (b) Blender's frame-suffixed name ends at the requested
path with nothing left beside it; (c) a render that writes nothing gives a
plain reason (failed before: "was not written") and leaves no scratch; (d) no
scratch is left on success or when the move raises. The cleanup and suffix
checks were confirmed to go red when rmtree is skipped or only the
unsuffixed name is accepted.
…it is still that file (#692 review 4)

The scratch dir made sure nothing outside the call could be taken for its
output, but inside it the largest file still won: a render hook that dropped
a bigger .txt or .blend there would have been moved onto the requested path
and verified as the video. Only files with the extension Blender gives the
format being rendered (read from frame_path, since render.file_extension
still says .png after a switch to FFMPEG) are now candidates.

The receipt also trusted that the file at the requested path after the move
was the one moved there. Another writer replacing it in between would have
been passed off as the render, so the chosen file's size and mtime are
recorded before os.replace and checked after it; a mismatch is
verified: false with "the file at <path> changed after the render wrote it".

The no-output reason now reads "no render output file was found", which is
what the code actually knows once candidates are filtered by format.
…iew 5)

The literal-input rule looked `code` up by name in the node's merged
params. A node written `inputs: '{{ inputs.payload }}'` (or `config:`
templated with no `inputs:`, or a mapping `config:` beside a templated
`inputs:` string) makes merged_params() return a bare string, so the
by-name lookup found nothing and passed it. render_config then resolves
that whole-value template into the object the run supplies, `code`
included, so Python handed in at run time reached Blender under an
approved lock -- exactly what E_APP_INPUT_MUST_BE_LITERAL exists to stop.

When a command declares any literal input, a non-mapping `config:` or
`inputs:` that carries template syntax is now refused too, each field
checked on its own before merging can hide one behind the other. The
message names the literal input(s). Validate, compile (through validate)
and the runtime pre-render check all go through the one
literal_input_violations helper. Commands with no literal input keep
accepting whole-node templates.
…g it (#692 review 5)

On Unix Blender is spawned as its own process-group leader so a tree
kill can reach everything it started -- but that also means a terminal
Ctrl-C no longer reaches Blender. One-shot `aware app run` and
`aware agent invoke blender exec` install no Ctrl-C handler, so SIGINT
ended `aware` without running the tree-kill guard or kill_on_drop, and
a headless Blender kept rendering with its GPU context and output file
held.

The Blender wait loop now also waits on Ctrl-C (tokio::signal::ctrl_c,
cross-platform), including while the progress pump is blocked. On it
the whole tree is killed through the same kill_tree path the timeout
uses, any progress already written is drained (bounded), and the call
fails with an "interrupted" error so `aware` exits non-zero. The
interrupt is injected into run_script, so a test can trigger it without
sending a real signal. Windows keeps the same taskkill /T tree kill.
…inishes (#692 review 5)

The previous commit had each Blender call wait on tokio::signal::ctrl_c.
tokio's handler, once installed, stays for the life of the process and
replaces the default "Ctrl-C ends the program", so a one-shot run whose
Blender call had finished swallowed every later Ctrl-C while other
nodes ran.

There is now ONE process-wide watcher, installed lazily by the first
Blender call on its own thread and runtime. Each Ctrl-C is routed by a
pure rule: a Blender call in flight is woken (it kills its tree and
fails as interrupted, as before); with nothing in flight, a mode that
owns shutdown itself (long-running `aware app run`, which now claims a
CtrlCOwner for the life of the run) is deferred to; otherwise the
process exits with 130, the default SIGINT outcome.
…692 review 6)

Review 5 tried to make Ctrl-C kill Blender's process tree by keeping
Blender in its own process group (so a tree kill could target the group)
and having aware watch for Ctrl-C itself. That watcher approach kept
drawing defects: a start-up window before the watcher was installed in
which Ctrl-C was lost, a final progress drain that Ctrl-C could not
cancel, and on Windows a descendant that survived after Blender itself
exited. Per the operator's ruling ("Simplify") those two commits are
reverted and the group is dropped instead.

On Unix Blender is no longer spawned with process_group(0). It stays in
aware's foreground process group, so a terminal Ctrl-C (SIGINT to that
group) reaches Blender directly, as before this branch.

The timeout and abandoned-call (drop guard) paths still have to take
Blender's descendants down, and their Unix kill was `kill -KILL -<pid>`,
which only worked because Blender led its own group. It now walks the
tree instead: freeze each pid with SIGSTOP so it cannot spawn mid-walk,
recurse into the children `pgrep -P` reports, then SIGKILL it, children
before parents. The pid is passed as $1, never spliced into the script.
Windows keeps `taskkill /T /F` unchanged.

Tests: the argv test now pins the sh/pgrep walk and asserts nothing
targets a negative pgid; a new source-level test asserts the production
launch sets no process group (there is no Unix host to observe it on,
and Command has no process-group getter). Killing the whole tree on
Ctrl-C reliably is left to a follow-up issue.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-12T14:09:36.921356Z 2919552 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@pawellisowski
pawellisowski merged commit 2b2ff49 into main Oct 12, 2026
15 checks passed
@pawellisowski
pawellisowski deleted the feat/692-blender-exec branch October 12, 2026 14:07

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 291955278d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +413 to +416
schema:
result: any # run(ctx)'s JSON-serialisable return, at most 256 KiB
outputs: array # [{path, kind: video|image|blend|file, size-bytes, frames?, verified, reason?}]
frames: number # total frames rendered by ctx.render_animation calls

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Declare the scene receipt in exec's output schema

Add scene to this schema because exec.py always returns it ("scene": scene_receipt), and the new skill documents it as part of the success payload. As written, a downstream reference such as {{ sequence.scene }} is reported as an unknown output field during lock compilation, and --simulate synthesizes no scene value even though a real run provides one.

Useful? React with 👍 / 👎.

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.

blender: exec command (author-time bpy code) + per-frame progress on the builtin transport

1 participant