Repository navigation
feat(blender): exec command for author-time bpy code, literal inputs, live per-frame progress (#692) - #696
Conversation
…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.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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".
| 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 |
There was a problem hiding this comment.
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 👍 / 👎.
Summary
blender exec: the builtinblenderagent (0.1.0 → 0.2.0) gains anexeccommand. An AI writesbpycode 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 SketchUpexecwork. This lets FloLess users ask from chat for building animations: a construction sequence, a fly-through, a sun study or an exploded view.literal: true. A template there is refused at validate, compile and run withE_APP_INPUT_MUST_BE_LITERAL. That includes a whole-node template oninputs:orconfig:, so code supplied after approval can never run.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 #405node-progresschannel andAWARE_PROGRESS_FILE. FloLess shows "Rendering frame N of M" so a long render never looks stuck.sys.dont_write_bytecodestops__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
30-apps/)Decalog check
Verification
cargo fmt --check,cargo clippy --all-targets -D warnings, andcargo test --no-fail-fastall pass: 2748 tests, 0 failed. Run locally on Windows.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) andtest_exec_ifc_scene.pyall pass.__pycache__in the stored package.Notes for reviewers
registry-index.jsonstill offersblender@0.1.0only, 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.appandmodel-to-construction-sequence.app(requires: blender@0.2.x) need a local install of the agent.aware's foreground process group, so a terminal Ctrl-C reaches it directly. Timeout and drop kill the tree by walking it:pgrep -Pon Unix,taskkill /T /Fon 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.🤖 Generated with Claude Code