Repository navigation
Program engine M1.2: linked child runs and person asks for program steps - #6277
Draft
schickling-assistant wants to merge 7 commits into
Draft
schickling-assistant wants to merge 7 commits into
schickling-assistant wants to merge 7 commits into
Conversation
…gine-host' into schickling/2026-11-10-program-engine-child-runs
…the program asker
…gine-host' into schickling/2026-11-10-program-engine-child-runs
…gine-host' into schickling/2026-11-10-program-engine-child-runs
Contributor
Author
|
Review record for head
|
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #6265 (program engine M1.1b). Milestone M1.2 of
docs/st3/program-engine.md: §6 linked child runs and asks, §7 serialized sections.Problem
M1.1b runs program steps, but a program can only run its own effects. The bundle side already offers more (#6266,
fractal/effect):Fractal.run(mission, input), worker turns (Fractal.step({ id, success }).pipe(Effect.provide(Fractal.Worker.layer({ mission, input, product })))) andFractal.ask(...). Their host messagesrunandaskreach the daemon, and the daemon answers neither, so such a program waits forever. A serialized section (R09, a child run of aconcurrent-runs max=1mission) needs the same child runs.Goal
A program step can wait on a linked child run or a native person ask. The daemon starts it, learns its end reactively, journals the result under the program's key and resolves the program:
run: the run's evaluation creates the child withcreate_child_mission_run(parent_step_run= the program step, idempotency keyprogram:STEP:ATTEMPT:KEY, inputs from the message), or adopts it on resume. When the mission is at capacity, the start waits; the end of a run holding the capacity triggers the retry.ask: a native person ask on the program step, withwork ask --stepsemantics (structured requests, deadlines, defaults). The requester isdaemon/program. Several asks can be open at once; the step iswaiting-personand its program stays loaded.cancel_descendant_mission_runs_tx. A retried attempt starts new children.Exit shapes, matching #6266:
{ run, status: 'completed', products? }, products keyed by full subject (resource/mission-run/CHILD/…){ _tag: 'ChildFailed', run, status, reason }{ answer?: { type, outcome?, id?, label?, text? }, summary?, resolved_by }(resolved_by: the person ordeadline){ _tag: 'AskCancelled', ask }Decisions
startedentry for the key. The parent run's evaluation creates or adopts the child or ask, journals a secondstartedentry withchildset, and resolves once it has ended. Its reads (mission-run/CHILD, the ask subject, the runs holding a full mission) are noted, so the store transition that ends them evaluates the parent again (follow_mission_reads) and nothing polls. Thestartedentry is written before the request is registered and the engine's wake then touches the step, so an evaluation that acts on a request is always one the incremental scheduler marked.enforce_mission_run_capacitynotes no reads. Onmission-run-capacitythe evaluation notesmission-run/HOLDERfor each active run of the mission (active_mission_runs_for_mission); a holder's terminal claim re-evaluates the parent, which retries the start.ask_personwith adaemon/programbranch, not a second ask path. A program step has no claimant, so for this requester the claimant, incarnation and lease fence is replaced by: the origin is agentless, inready|working|blocked|waiting-person, its definition in the generation's revision runs aprogram, and the request'sincarnationequals the step's current attempt. Subject hashing, idempotency, deadlines and defaults, projection andperson_answersare the agent path's.current()already treatsdaemon/*requesters as unfenced, so the ask lives while its run lives.PROGRAM_ASKERis a kernel constant shared by store and engine. The legacy work API (POST /v1/work/ask, cancel-ask) takes the actor from the body, so it now refusesdaemon/program(reserved-actor);client_v0already passes the authenticated actor.cancel_waits(step, attempt)cancels eachchildthe attempt journaled under a key that has no result yet. Child runs are cancelled withrequest_mission_run_cancellation; asks withfinish_person_step(cancel)as their requester (astale-fenceanswer means the ask already ended). It runs fromrelease_left, fromstop_if_left, when a program ends (done: children left behind by a lost race) and when a worker isunresponsive. Children already ended and runs already terminal are skipped, so cascades are not repeated.waiting()always journals thestarted+childentry (the request keeps its bundle hash). If the attempt no longer runs and its step has left the attempt,waiting()cancels it itself. Every path that ends a program sets the step's state before it reads the journal to cancel (done,fail,unresponsive;release_left/stop_if_leftact on a step that already left). So either that cancellation sees the new entry, orwaiting()sees the step left. A program removed only because its host exited is not cancelled: it resumes and adopts the child.internalpropagates (the evaluation records a fault and the next one tries again, asuses-missiondoes). Other codes are refusals and fail the step.waiting-personprogram step to the engine (before the status's early return), so the program replays to the same wait and keeps resolving its other asks and children. Its step timeout stays paused, as for a worker's ask (DQ6).start_program_childis the only call that creates a program's child. Mission-run authority (Mission run authority: parse, render and store the authority block #6260 and its follow-up,create_program_child_mission_run, which writes the creation asmission-run/PARENT) is not on this base yet; when it lands, this one call switches. The requester stays the parent run's requester.Verification
Focused tests on the Linux build host at head
728faf2150(#6265 @3ba3725965 merged),nix develop -c nice -n 19 cargo test -p fractal-reconcile --lib -- program:Mutation checks (each applied alone, the named test re-run, then reverted):
waiting()never cancels a late childa_child_started_after_its_step_left_is_journaled_and_cancelleda_child_of_a_full_mission_waits_and_starts_when_the_holder_endsrelease_leftcancels no waitschildren_are_cancelled_with_their_run_or_their_step_and_a_retry_starts_a_new_oneNew integration tests (
reconcile/tests/program_children.rs) run a real Bun host with a small hand-written bundle against HostContext v1:a_child_run_is_adopted_on_resume_and_its_result_is_journaled_and_replayed: killing the host while the child runs leads to the same request, and the daemon adopts the same child; exactly one child is linked to the step. The completed child's value is pinned, products included. A host killed after the result was journaled replays it without a new request.a_child_of_a_full_mission_waits_and_starts_when_the_holder_ends: with aconcurrent-runs max=1mission held by another run, the start waits. Cancelling the holder and then oneevaluate_changed_mission_runs()(no pass) starts the child (reactive wake); the serialized section then completes.children_are_cancelled_with_their_run_or_their_step_and_a_retry_starts_a_new_one: a parent run cancel cascades to its child. A program step failing with an attempt left requests its child's cancellation in that evaluation while the run keeps running. Attempt 2 starts a new child. A failed child pins the exactChildFailed {_tag, run, status, reason}shape.a_child_started_after_its_step_left_is_journaled_and_cancelled: an evaluation holding a request from before the step left starts the child afterwards; the child is journaled and cancelled.asks_are_answered_defaulted_or_withdrawn_and_their_answers_replay: three concurrentdaemon/programasks;waiting-personkeeps the program loaded; one answer resolves while the others stay open; a replaced host replays the answer and adopts the open asks; withdrawal givesAskCancelled { ask }; the deadline applies the default (resolved_by: deadline); every exit is pinned in the journal and in$result.messages_for_another_attempt_or_host_change_nothingnow also coversrunandask.Not covered by a test: the
internalerror retry (a store failure cannot be injected through the public store), the pre-start holder read race, and thedaemon/programbound to program steps and its HTTP refusal.fractal-api-corewas compiled (cargo check -p fractal-api-core --lib); the large-value inventory check passes.Hosted CI at the exact head is the merge proof. Pushes skipped the local pre-push evidence (
ST_EVIDENCE_LOCAL=off) because the authoring machine is overloaded.Complexity
About 230 lines in the engine (request registry,
waiting/resolve/fail, journal-basedcancel_waits), 190 in the reconciler (settle hook, child exit and products), 20 in the store ask path, 430 in tests and docs. The engine and the reconciler are split by thread: only the evaluation can note reads, so starting and resolving live there and the engine keeps what the host asked for. No new abstraction beyondRequest/Wait.Concerns
Requeststate is in memory. After a daemon restart the program re-sends its requests (replay), which adopts children and asks by key. A step that left while the daemon was down has its children cancelled only by a later run end.[INFERENCE]That window is rare: the step would have to fail or time out with no daemon running.Friction & bottlenecks
branchy newbranched frommain, which carried main commits Program engine M1.1b: run program steps on a supervised Bun host #6265 lacks. The stacked diff was then unreadable, so the change moved to this clean branch from Program engine M1.1b: run program steps on a supervised Bun host #6265 (schickling/2026-11-10-program-engine-child-runs). The canonical worktree guard on the build host blocks switching a worktree's branch, so the build host needed its own worktree for it.Follow-ups
start_program_childtocreate_program_child_mission_runonce the mission-run authority change lands (the creation is then written asmission-run/PARENT).childof each open key inmissions show/waiting-on.References
docs/st3/program-engine.md§2, §5, §6, §7, §11 (updated here)