Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 20. října 2026

Procházet dokumentaci · Delegation and workflow runtime

Tento článek zatím není přeložen. Je dostupný v angličtině.

Developer reference

Delegation and workflow runtime

Fresh-worker prompt composition

LiveSessionSpawner selects the worker route for ordinary non-fork sub-agent sessions, independent of whether the plugin resolved a typed role or supplied a generic role. personaTemplatesFor returns elowen-engineering and delegated/worker; plugin work templates cannot replace this foundation. Tool declarations and the worker contract supply the minimal harness guidance. Conversational identity, memory-writing guidance, chat style and the shared-room overlay are absent. Core adds no recalled memories. The stable append order remains model identity, tool catalog, projected plugin fragments, role/context from the durable scope, then escaped account instructions; project context and cwd remain with the existing resource loader.

registerText('system-prompt', text, {interactiveOnly:true}) marks interactive-user guidance. The existing sessionPromptFragments projection drops it only for fresh workers; AskUser uses this option. Unmarked fragments and the existing SkillLoad filter retain their order and behavior. This changes text, not tool authority. Review keeps tools: inherit and a prompt-only no-edit scope; its findings must name concrete reachable defects, and zero findings is valid.

Forks continue through forkPrefix.ts with the parent's byte-identical prefix and toolset. Scheduled, memory-dreaming and internal skill-review paths stay separate. Composition runs once per spawn, adds no provider calls, and reduces the fixed worker prefix. With no role append, the worker contract still supplies scope, evidence, parent reporting and blocker handling.

Durable delivery authority

The awaited transcript method settledResultIds derives delivery from settled, undiscarded custom journal entries, including entries before compaction. Ordinary results are acknowledged in the same transaction that appends their settled custom_message journal entry carrying details.resultId; the post-commit onResultDelivered callback publishes the delivery event. Streaming parents receive results through the native PI steering queue in FIFO order, after the current tool round and before the next provider request. Idle owner parents batch new results into one follow-up turn. Failed boot recovery delivers an ordinary error result and wakes the parent without user input; the parent verifies the last step before deciding whether to use DelegateContinue. A parked owner resumes through that result or its existing restart continuation, never discards the park because of a recovery failure. A failed parent answer does not requeue an already durable ordinary result. This read uses one repeatable-read snapshot and decode original JSON in TypeScript.

ChannelSessionService.steerDelegatedTurn uses the same journal proof and queuedResultIds(session) queue read for nested results. It writes no custom steer receipt and never stamps details.delegatedSteerId on a result. Remote delivery carries {type, resultId} in the existing steer frame; the runner checks its pending inbox before calling the same channel steering seam, while ordinary text still resolves its committed receipt. If the turn ends during the journal read, channel delivery returns idle instead of stranding a new message on an idle PI queue. Clearing an unconsumed PI queue leaves its inbox intent pending for the next drain; a committed journal result is never re-steered. Text follow-ups retain enqueueDelegatedSteer(childSessionId, text) receipts and their delivered/dropped notices. Existing custom rows in brain_delegated_steers are inert history: no migration, delivery, cancellation or turn-end/boot sweep changes them. The child's call still requires its turn to end, no live helpers and no unprocessed helper results before it settles. DelegatedSettlement.runChildTurn is the shared bracket for text follow-ups and custom result turns admitted by DelegatedSessionService.sendDelegated while a call is settling. A workflow completion may start such a turn independently of the settlement drain: the bracket holds every open caller until the turn finishes, then returns the newest settled child reply. Committing a summary does not release that turn. A provider failure after acknowledgement fails the open call without requeuing the committed result. PendingResultDrainOutcome.failed carries its result id, reason and original cause independently of pending inbox attempts, including for callers joining the same drain; an aborted outcome retains stop precedence. The cause and a settling call's failed-turn Error are in-memory only. Settlement keeps native cause on contextual errors for helper delivery, concurrent child turns and job reminders (including jobs lost to restart), so recovery diagnostics retain typed instance conditions without changing persisted result payloads. The bracket supplies the open call's progress sink to result turns that have no explicit caller sink. Their real text, tool and compaction events renew the delegating plugin's stall watchdog; a delivery turn must not appear silent while it is working. Session identity events are excluded from this inherited sink, and an explicitly supplied sink is not duplicated. Text follow-ups use the same forwarding path. The bracket adds no queue, timer or second delivery authority.

The model-facing contract is to keep doing independent work after background delegation and end the turn only when no independent work remains. While the parent is active, results arrive after the current tool round and before its next model step; only an idle parent needs a new turn. There is no wait/Sleep tool, result polling loop or deliberate delay. A workflow delivers one whole-workflow summary, not individual node results.

Channel turns bind one completeDelegation dependency as emitDelegationCompletion, exposed through ctx.delegationCompletionEmitter(). Its discriminated DelegationCompletion accepts either a sub-agent result or one whole-workflow summary; progress remains separate. A platform root without a delegated parent has no completion sink and retains its room continuation path. The bundled Sub-agent plugin is the consumer; queue reads add no provider call and long-running tools are not preempted. Each newly queued result emits the local-only delegation.result_steered info log, including nested parents.

Local target turns in runners

Local children inherit the parent's {kind:'local',targetId} reference and original connection. Daemon admission holds a Sandbox binding before dispatch and supplies transient LocalExecutionTarget metadata in DelegatedTurnRequest.localTarget; that snapshot is validated at IPC admission and never persisted as connection identity.

The durable scope parser accepts absolute drive and UNC placements only for local references, alongside POSIX paths. It validates projectRef before workDir and exact canonical sharedArtifacts; target admission then checks placement against the computer's actual platform. A Windows C:\Users\owner\repo placement therefore survives persistence, IPC and continuation without becoming a server path. Ambiguous drive-relative and current-drive paths are refused. Artifact grants use the artifact resolver's canonical forward-slash spelling, including Windows drive and UNC locations. Host and managed scopes continue to require POSIX paths.

Files, Terminal, LSP and MCP in a runner forward sandbox.local through the existing host RPC to the daemon, where Sandbox resolves core's localExecution host control (paired computers). Admission checks the registered owner, every delegated scope and current account grants; Sub-agent's WorkflowStart is additionally admitted only for definition file reads. Read-only LSP/MCP transport requires a live declared read-only tool and allows argv launch and protocol stdin, never shell launch or file mutation. Core capture carries a separate typed artifact:true intent from the core-only sandboxArtifacts control, stamped as Sandbox without a tool; daemon routing uses that same control and the root pin, with every path and destination confined to the selected computer's ~/.elowen. Ordinary model-selected file paths never select this authority. The canonical socket operation/result types remain in packages/plugin-shared/localExecution. Host results may carry final:false for process-started, stdout, stderr and shell-cwd frames; final exit settles the same request. There are no polling credits or automatic retries. Descendant session metadata is authorized from durable ancestry and scope before using the root pin. Follow-ups reenter the same live RPC context, including startup metadata.

The existing dispatched-call ALS now lives in src/subagent/rpcTurn.ts. Its local lifetime carries the daemon-admitted target and contribution account, and aborts on the runner's real abort frame and at settlement. Sandbox exposes it through localExecutionTurn; cold startup instruction reads and the channel turn acquire abort subscriptions through the same bindLocalTurn contract as daemon turns. Their releases detach subscriptions, while only the daemon acquires and releases the socket pin. No second context, connection registry or startup RPC is created.

A local Bash(run_in_background:true, backgroundMode:"service") uses the existing process registry for lifetime and cancellation, like host and managed services. After the daemon receives process-started for a service request, SubagentRunnerHost transfers its abort controller from the pending turn's RPC set into its retained service-stream set. A result or error retires turn authority without cancelling that admitted stream; unadmitted starts and other RPCs remain turn-bound. Registry cancellation still sends hostCancel with the original call and turn ids, and final exit or error removes the retained entry. Runner exit aborts every retained stream. Sandbox's local runner service lease does not heartbeat the settled dispatch signal; the same streaming RPC reports transport loss through session.closed. New start and stdin requests still require an active admitted turn. No service holds delegated settlement, opens another connection or gets replayed after disconnect. The only extra state is one retained controller per admitted live service.

Computer connection loss interrupts the admitted runner lifetime even during model-only work, but never invokes the user Stop/tree-cancellation lifecycle. The existing abort IPC carries RunnerInterruption with origin:'connection_lost'; the runner signals the local binding with typed LOCAL_EXECUTION_LOST and lets that binding stop PI. Queued calls are removed without dispatch. Runner error IPC preserves typed computer-unavailable evidence, rather than inferring it from error text. Each validated incoming turn registers its existing RPC lifetime synchronously before waiting for boot or a catalog publication. An abort received during either wait remains on that context and is checked before entering the brain, so revoked work cannot restart or issue host RPC after readiness; settlement removes the same context and dispatched edge on every exit. No stale-abort registry or retry is added. A real Stop still follows the existing terminal abort tree.

After the original live delegated dispatch releases its fence, DelegatedSessionService.parkDispatchedComputerCall resolves its exact native run and reuses DelegatedRecovery.waitForComputer. The prerequisite retains the original Project, child, tool-call id and row generation; parking spends no recovery attempt. Core throws host-only DELEGATED_COMPUTER_WAITING only after that wait is durable. The bundled Sub-agent plugin drops its volatile job and stall timer without emitting terminal progress or completion; the native coordinator owns reconnect/boot continuation and inbox delivery. Workflow nodes have no delegated run row and remain owned by their native engine. Optional skill reviews do not create recovery obligations. No new timer, socket registry, fallback or executor replay is introduced.

Daemon IPC disconnect retains the existing process-exit/recovery behavior and does not abort recoverable rows through the tree. Before closing its pool, exitRunner awaits the existing brain pauseForRestart() checkpoint, which drains captured journal writes and publications without waiting for model/tool turns. A finished tool result queued behind an in-flight write must commit before the pool stops accepting connections; a failed checkpoint logs the shutdown failure and exits nonzero. Boot and reconnect recovery continue interrupted local children/workflows through their native prerequisite claim and settlePartialTurn; they never replay their old executor operations. Actual continuation dispatch charges one durable recovery attempt, retained after completion; boot ownership and offline waiting charge none. A channel continuation validates its exact native prerequisite claim and current wake before acquiring a fresh original-Project binding around cold startup and the whole turn. Its durable parent need not hold a live socket pin; nested startup/turn bindings reuse this new admitted child pin. Ordinary fresh delegations still inherit the parent's socket and cannot move to a replacement.

Core owns delegated execution, authorization, process dispatch, persistence and recovery. The bundled Sub-agent plugin owns the Delegate and Workflow tools, the workflow DAG engine, agent definitions and orchestration policy. Both a Delegate call and a workflow node use the same host dispatch rather than creating separate execution runtimes.

Owning modules

  • src/subagent/dispatch.ts: the one decision between in-process execution and a runner process.
  • src/subagent/pool.ts, runnerHost.ts, runner.ts and protocol.ts: runner allocation and the typed process boundary.
  • src/subagent/hostRpc.ts: bounded host requests and cancellation across that boundary.
  • src/subagent/modelCatalog.ts: the runner's publication fence — it restores the process's public runtime and every prepared per-account runtime on a modelsChanged frame, so a warm runner serves each account the catalog its own key was last allowed to see.
  • src/brain/delegatedScope.ts and delegatedTurn.ts: durable execution scope, live policy reconstruction and the shared delegated request.
  • src/brain/service/delegatedSession.ts: parent/child controls, workflow continuation and dispatch settlement.
  • src/brain/service/delegatedRecovery.ts and delegatedSettlement.ts: recovery ordering and completion of child-owned work before publishing the parent's result.
  • packages/db/src/repositories/delegation.ts: asynchronous DelegationRepository contracts exported by elowen-db; packages/db/src/delegation/ owns the PostgreSQL transactions and parent/principal-scoped reads.
  • plugins/subagent/lib/workflow.mjs: workflow definition, dependency handovers, orchestration journal and shared outputs.

Dispatch and failure

For ordinary delegated work, SubagentDispatch.send holds the same remote-call fence for either execution mode, including queue waits. Startup failures never replay delegated work on the daemon's event loop. The parent receives the typed error through the existing delegated tool failure path.

Condition Dispatch outcome
Runner enabled, available slot Execute in the runner.
Runner enabled, slots occupied Wait in the pool's existing fair queue; abort removes unstarted work.
build_mismatch Throw SubagentRunnerRestartRequired, code restart_required. No local execution or further fork attempts in this daemon generation. Restart the daemon after deploying a new build.
fork_failed, boot_failed, boot_timeout Retry only within the pool's current finite startup episode, then propagate RunnerStartupError. No local execution; a later delegation does not renew an exhausted episode.
Memory capacity 0, exited host, closed IPC before send, reset during boot, pool shutdown/reset Propagate SubagentRunnerUnavailable. No local execution; shutdown must not start more daemon work.
Failure after a runner accepted the turn Propagate the turn failure without replaying its side effects.
Explicit runtime.subagentRunnerEnabled = false or pool maximum 0 Execute through the existing in-process path, preserving concurrent and nested delegated calls.
No runner dependency inside a forked runner Nested turns execute in that already-isolated process, without a new fork.
Opportunistic skill review without a warm slot Refuse optional work without a boot, queue or local fallback.

When the pool latches build_mismatch, attachRestartRequiredSink invokes the daemon's AlertService producer once. buildBrainCore attaches it to the pool constructed by bootstrap; it raises the warning core:subagent-restart-required for current administrators, localized in Czech, Slovak and English, with an instruction to restart the Elowen daemon so sub-agents can run again. The stable key updates the same condition rather than duplicating it. On daemon startup, core clears that alert before attaching the new sink because the old process's latch is gone. Runner cores have no pool and never clear the daemon's alert. Plugin reloads and runner-mode changes do not clear it. Other startup failures do not raise this restart warning. An absent sink leaves typed dispatch failures intact; alert-persistence failures are logged and do not replace the original startup error.

readSubagent uses the canonical lastSettledAssistantText reader, which reads assistant bodies in newest-first pages of 32 through recentAssistantMessages until it finds a non-empty answer or exhausts the rows. It keeps the answer's original whitespace after any number of empty failed continuations. The ownership guard runs before this read; the trimming lastAssistantText wrapper is not used here. Ordinary last-row reply readers and goal judging need only one assistant row. Interrupted-tail settlement and dreaming error checks use getLatestTurn, which includes provisional rows and custom chat events. Fork-prefix diagnostics page assistant rows until a usage-bearing response appears. Workflow output capture in src/daemon/pluginAdapters.ts still needs every tool-result reference across the producer's context; an assistant-only tail cannot replace that read.

When every pooled runner is full, an ordinary delegated turn waits in the pool's fair queue rather than being refused. Its child session, run row and workflow node are persisted before the wait, and the pool reports the session as queued until dispatch starts it. Aborting a channel removes its queued turns and rejects them with DelegationAbortedError, so they never run. Boot recovery terminalizes a Delegate that never started. The plugin's background job cap still applies on top.

Host-created children use captureDelegatedExecutionScope(scope, authority) in src/brain/delegatedScope.ts before dispatch. Delegate/workflow, memory dreaming and post-turn skill review all supply their authenticated account, permission boundary and project target here. The pure capture validates the complete scope, intersects its tool allow-list with the current account grant, unions denials and returns detached canonical JSON. Missing authority adds no restriction; an explicit empty allow-list grants no tools. Invalid scope throws. Work is bounded by the existing scope normalizer limits and performs no I/O. Ordinary delegation authenticates the captured operator flag through IdentityResolver.isOwner, including additional account administrators.

Dreaming supplies its host-only memory identity, imposed permission boundary and account principal. Its memory/tool classification and own-target Project switch stay in their existing capability and project boundaries. Skill review supplies the parent's project and fork flag; composeSkillReviewRequest keeps the exact source catalog while the execution gate refuses every tool. The capture never composes prompts or tool catalogs.

delegatedChannelSendOpts builds the request for both execution modes and for DelegatedSessionService.sendDelegated continuations, helper wakes and recovery. Its optional fourth argument carries current account/caller denials without changing the durable scope. Continuation-only delivery, model and read-only-call state remain with the continuation owner. Serializable scope travels across the boundary; policy closures, tool Sets and identity are reconstructed from that authority. Live account grants intersect the captured scope and never widen it. The captured working directory is replayed from the durable scope for later continuation, rather than inferred from the parent's current directory.

A continuation requested from a read-only turn (plan mode) runs the entire delegated call read-only. DelegatedSessionService.continueSubagent mints readOnlyContinuationScope from the caller's current access and the child's stored scope, then binds it to the exact tool-call row as host-owned brain_subagent_runs.read_only_scope before dispatch. The Sub-agent plugin passes its call id through ctx.continueSubagent(sessionId, text, callId, onEvent?, model?, promote?). The host performs asynchronous admission before emitting session/progress events or returning a queued receipt. The plugin publishes progress only after receiving those events or successfully awaiting the receipt; an admission refusal emits neither progress nor completion. Progress updates preserve the host-owned scope column. Every sendDelegated reads the open call's scope, including background-job results, the pending-helper-result drain, follow-ups inside settlement and recovery after a daemon restart. ChannelSessionService.delegatedExecution executes each turn under readOnlyTurnScope while the child's permanent scope keeps composing the session. The additive nullable column leaves existing calls unchanged. Running and recovering rows hold the clamp; terminal or parked rows do not constrain a later Build continuation. A planning caller must wait for an active child, and a Build caller must wait for a read-only call to finish rather than steering into it or starting a settlement follow-up. write_access remains refused from a read-only turn. The plan-mode admission rule and clamp are documented in docs/modules/brain/turns.md.

Each plugin-owned delegation or continuation snapshots its progress updates and serializes their asynchronous publication in the same job. Foreground return and background completion drain those writes before settling the job, so progress cannot outlive its owning call. Ctrl+B awaits persistence of the background row before releasing the foreground waiter. Background completion also awaits the host completion emitter; output-writer capture and the inline-byte limit use their existing asynchronous host contracts. Publication failures retain the existing typed outcomes and diagnostics.

createCompletionDelivery prepares both background child results and whole-workflow summaries before the unified inbox handoff. It compares the full result's UTF-8 bytes with toolResultInlineBytes(): only an above-limit body uses the captured recipient writer and the core tool-output spill store. The stored bytes stay complete; the inbox and parent journal carry the ordinary bounded placeholder with its path, original size and preview. The parent uses Read on that path, in its host or managed session namespace. The shared helper reports a failed write as an explicit error with a DelegateRead recovery hint, not a truncated successful result. Spill preparation does not acknowledge delivery; only the parent journal commit does.

Fork visibility and retained context

The shared session factory awaits JournalRepository.copyJournalBranch outside its writer transaction. New fork children are durably private until the selected context, native ancestry seeds and final active leaf have committed. Copy batches yield and use asynchronous writer admission; partial children stay absent from transcript, ownership, search, listing and usage readers. Daemon boot discards unfinished private copies before claiming delegation recovery, preserving the parent's run so its normal missing-child result path settles the call.

A terminal ordinary delegate remains available to DelegateContinue, including after offline archive restoration. Terminal host-owned dream runs are noncontinuable through the shared assertForkContextAvailable predicate. Their old inherited prefixes may be reclaimed by existing checkpoint retention only after the same idle and parent-delivery checks used by child archiving. The child's own output remains available through the ordinary ownership guard and readSubagent. The store module owns bounds, markers and costs.

Working directories

Delegate's absolute cwd and WorkflowStart/WorkflowAddNodes node cwd share delegatedWorkDir. It validates against the child's own live policy and persists DelegatedExecutionScope.workDir together with its execution target. Scoped callers stay inside the selected host project; managed callers keep their guest mount and inherited narrower root.

An all-access administrator with canExecuteHost() may select any existing readable host directory. projectForPath resolves a registered host project; projectContextRoot also proves a linked Git worktree through Git's gitdir/commondir files. Core records that project's host ref, or the nameless admin-only host target for an unregistered directory. For example, a workflow node placed in a sibling worktree of its origin repository retains that repository's ref but reads AGENTS.md and .elowen/rules from the worktree. Instruction eligibility remains the existing owner-ancestor rule.

effectiveTurnWorkDir and read-only status preserve a validated worktree placement under its instruction root. sendDelegated replays the stored cwd on continuation and boot recovery, and channel admission refuses a vanished directory or one that no longer matches the stored host target. Admin host refs may differ from their host parent's ref in scopeExceedsCurrentAccess; scoped and managed parents still require the same target. Policy, tool grants, permission boundaries and ancestor checks remain the execution ceiling. Per-call assertPathAllowed never derives authority from cwd.

Workflow handover and recovery

The workflow engine calls the required PluginContext methods directly for dispatch capability, channel session identity, completion delivery through the single delegationCompletionEmitter(), output capture, working directory and expansion RPC. It awaits captureToolOutputWriter() before storing the origin-bound summary writer and currentWorkDir() before storing placement on start and explicit resume. It awaits delegatedTurnsOutOfProcess() before deciding whether a node may extend its workflow locally or needs the runner expansion capability. Missing delivery or RPC is represented by the accessor's null result; the accessor itself is always supplied by core.

Each workflow captures immutable progress snapshots and serializes their asynchronous publication through its own run state. Ctrl+B persists the background snapshot before releasing the foreground waiter; a failed write is diagnosed and leaves that waiter attached. Artifact and output-grant changes await their snapshots before handing results to dependents, and normal completion drains pending publication before returning the summary. Cancellation starts the summary before awaiting that publication, preserving the distinction between an interrupted running node and a pending node that never started. The recovery journal remains until terminal publication settles; normal completion prepares its summary after that publication. WorkflowAddNodes awaits the same addNodesFromSession mutation used by the runner control before reporting the added ids, including extensions requested from a node that starts after the origin turn.

WorkflowStart reads local and managed definitions through ctx.readManagedProjectFile, the existing target-authorized project-file seam, and never probes the host for those targets. The core reader resolves local paths with the selected computer's platform and current turn directory through resolveLocalPath, so Windows drive and UNC definition paths reach the same bounded, version-pinned read as POSIX paths. Managed definitions retain the absolute guest-path contract. The node validators receive the selected projectRef, accepting Windows drive and UNC cwd only for a local target. Core still validates and persists every placement through delegatedWorkDir.

A node's WorkflowAddNodes expansion copies its target, spawning principal, settings and contribution accounts, managed root ceiling and read-only provenance alongside tool and permission restrictions into nodeParentAccess. These fields survive the existing journal and dispatch path. Dropping target or principal would make the resulting child impossible to continue under the same parent. DelegateContinue and workflow continuation still reject a different target before dispatch; stored text and progress use their existing durable parent/principal authorization.

Explicit WorkflowResume captures the origin turn's current access and cwd again. Existing child sessions retain their validated placement; fresh nodes under a changed boundary use the newly selected target and cwd. This is a new authorized request. Automatic recovery of interrupted local workflow or delegated mutations remains terminal with LOCAL_EXECUTION_LOST; it never replays unknown effects or chooses the server.

A node receives short handovers from its direct dependencies, not every upstream transcript. The engine records the workflow and node execution state in its version-2 orchestration journal, including node access/model maps, type-model pins, the run number and a handover for every completed node. Recovery requires this current shape. Old-format journals are rejected through the same invalid-journal path: core terminalizes only that workflow and reports that it could not be resumed; other workflows continue recovering. Shared result artifacts have durable recipient grants, so the parent and authorized direct dependents can read the same output without a copied transcript authority. Before dispatch, PlatformOrchestrator mints these grants from stored dependency ids and hashes through workflowArtifactPath. For local children it resolves Sandbox target metadata under the child's contribution account and passes that metadata to the shared artifact resolver. Caller-supplied paths never mint grants; an unavailable or unauthorized computer refuses dispatch without choosing a host path. Minimal compositions without the dependency callback mint no grants. This adds one target-metadata lookup for a local child with dependencies and no artifact I/O.

Before replaying journaled authority, WorkflowResumeRecovery.journaledBoundaryCheck resolves a delegated origin through DelegatedSessionService.currentDelegatedAccess(userId, sessionId, currentAuthority). It reads the origin's persisted DelegatedExecutionScope through the same delegatedContinuation admission as a resumed child, rebuilds identity, project policy and tool grants through IdentityResolver.forDelegatedTurn, delegatedPolicy and delegatedToolPolicy, and checks every durable ancestor with scopeExceedsCurrentAccess, the DelegateContinue comparison. Journal replay uses that comparison's verbatim tool mode: every allow-list entry must be fully covered by current access through the shared listCovers matcher. Merely overlapping a held tool is insufficient because the journal's patterns are forwarded unchanged into fresh workflow nodes; ordinary child continuation retains its existing clamp to the caller's grant. An owner conversation's delegated child retains its inherited owner authority after restart. Current account existence, project access, tool allow-lists, permission settings and ancestor scopes must still prove that authority; malformed scopes, cycles and cross-account parent links refuse recovery. Channel and cron roots do not acquire owner authority from their storage owner. The host supplies current policy and permission resolvers; journal fields are only compared, never used to grant access. The asynchronous check awaits account and ancestry repositories without starting a provider turn or adding persistence. The required WorkflowControl.resumeInterrupted hook validateBoundary(access) returns Promise<{ ok: boolean; reason?: string }>; the bundled workflow engine awaits the parent boundary and each node boundary before launching any work. A rejection refuses the entire resume.

continueWorkflowNode checks the durable parent relation before continuing. Automatic recovery reuses a settled answer after the child's own delegated work has settled; an interrupted turn resumes through its interrupted-result context, and an empty transcript requires the workflow's original task. The live origin bridge treats a supplied fallbackPrompt as an explicit retry in the same stored child scope under native workflow admission. For a paired child, settleInterruptedChild(..., workflowRetry) locks the native owner and exact workflow generation/admission epoch, settles the old tail and appends the new user task to the canonical child journal in one transaction before parking. The normal coordinator obtains the actual computer claim; wake or boot then continues over that retained input without appending the task again. A cached child is rebuilt from the settled journal before message-free continuation. Later interrupted mutations retain their existing verify-before-repeat tool results, not another retry task. Non-local retries send the task directly. Neither path reuses the failed attempt as the new result. Omitting the prompt retains recovery settlement semantics. Recovery does not launch a second node merely because its live runner was lost.

A minimal real consumer is the bundled Sub-agent plugin's WorkflowStart: it parses one DAG definition, dispatches ready nodes and forwards only their dependency handovers. Work consumes the selected model's normal account usage and runner resources. Read the plugin control and result contracts and user workflow guide for their respective audiences.

Canonical live input and host-only fresh hook

The live context and delegated-child bridge use canonical WorkflowNodeContinueInput (native admission plus optional onEvent and fallbackPrompt) and WorkflowNodeContinueResult; there is no positional child/text overload. The daemon recovery hook alone accepts optional startFresh: () => Promise<string>. Its targeted waiting-node callback uses the same hook type. A committed waiting.prerequisite is required; empty means no continuation, not successful parking or permission to infer child completion.

Canonical live input also declares optional computerUnavailable?: true for park-only reporting after typed local offline/loss. Native proof and an actual committed prerequisite remain required; this intent must NEVER dispatch, including after reconnect, and declaration-only tests do not prove native/engine adoption. See live input.

The host's existing policyContext ALS can carry HostWorkflowNodeDispatch through runWithWorkflowNodeDispatch/currentWorkflowNodeDispatch: exact admission and optional current-socket predicate, same policy/identity/session/turn token, no new store or callback map. This proof and fresh callback never enter live PluginContext input or runner RPC. Only actual native automatic missing-child recovery can use the existing in-daemon placement; ordinary configured runner, skill-review and startup-failure behavior remains unchanged, with no fallback. The declarations and scope implementation do not establish service/engine/facade adoption. See the host-only boundary.

The real PluginContext and daemon adapter forward the SAME single input and current host access to the three-argument BrainService facade. Existing live ALS, bundled-plugin and process/brain guards remain; exact outcomes, callbacks, native claim and thrown values are not reconstructed. Real-registry/ALS bridge tests use a canonical narrow host stub: they prove forwarding/guards, not native CAS, missing-child dispatch, the service facade implementation or engine publication. No dispatch-proof wrapper or fresh callback is invoked by this live bridge.

Protected automatic missing-child placement

For a host-only workflow ALS scope, PlatformOrchestrator matches BOTH the actual source parent and channelSessionId(keyOf(source)) to the native reference. Absent child identity is not a wildcard. Only an exact match requires assertWorkflowNodeDispatch(dispatch, childSessionId); missing callback fails loudly. It awaits that SAME delegated-session assertion BEFORE policy, cwd, dependency, local-target, artifact or scope preparation. Native queries/claim/ Project validation stay in the delegated-session owner, reached through the thin BrainService callback; this layer neither parks nor admits/charges an attempt.

The exact isCurrentConnection predicate is rechecked after assertion and every awaited policy/cwd/dependency/target/artifact/authority/owner/origin operation, including immediately before synchronous dispatch construction. False throws existing SessionWorkAbortedError; native continuation owns conversion of its stale-socket refusal to the actual wait. Typed preparation failures propagate unchanged for the shared offline/lost cause classifier, with no prose parsing, host-path fallback or hidden reattempt. Existing scopes and grants only narrow.

SubagentDispatch.send keeps skill-review FIRST. An exact protected parent/child match then selects the existing in-process fenceRemote/runTurn branch before runner mode, the explicit daemon placement for AUTOMATIC missing-child recovery just like stored-child recovery. Ordinary live, mismatched/missing child and nested descendant calls retain configured placement; runner startup errors do not fall back/replay locally. No request/RPC authority or new dispatch registry exists. With no protected scope there is no assertion work. Focused tests use the real orchestrator/dispatcher/ALS with controlled native/runner boundaries; they prove ordering/placement, not assembled native authorization or final CAS.

Native recovery control declarations

WorkflowRecoveryControl uses canonical WorkflowRecoveryAdmission, ComputerPrerequisiteClaim and ComputerPrerequisite through erased imports from elowen-db, never copied JSON authority or guessed native generations. resumeInterrupted requires host-proven recovery:{workflowGeneration,recoveryAdmissionVersion}. Its continueNode hook takes one admission input plus optional onEvent and fallbackPrompt, retaining the existing event stream and stored delegated scope. Results distinguish answered/continued with reply, empty, and waiting with its committed native prerequisite. Waiting is neither empty nor complete and cannot trigger fallback dispatch or terminalize the node merely because the computer is unavailable. Core rechecks admission after async authorization and charges only actual dispatch.

resumeWaitingNode requires the native admission, exact prerequisiteClaim and a continueNode callback derived from Parameters<WorkflowRecoveryControl['resumeInterrupted']>[0]['hooks']['continueNode']. It targets the exact held node and returns {status:'accepted'|'stale'|'not-held'}. Acceptance queues only that node; it is not dispatch admission, another charged attempt or a whole-DAG resume. Existing boundary validation still checks the current authority of every journaled scope. Explicit WorkflowResume separately uses the host's advanceWorkflowGeneration({toolCallId,workflowId}) terminal-to-running bridge; boot must retain native generation and budget. See the scoped bridge and native admission. The context now enforces bundled-plugin/live-ALS/current-principal availability and the daemon adapter forwards exact parent/input/access to the existing brain method, preserving receipt identity, undefined and errors. Component forwarding tests do not prove native authorization or engine journal atomicity; boot and automatic recovery must not call this explicit-resume bridge.

The native recovery host supplies that callback for the accepted node wake. It captures the real wake.isCurrentConnection and forwards it through the existing fifth argument of delegated continueWorkflowNode, preserving the same owner and post-authorization dispatch checks. A DB claim alone is not current-socket proof. No per-claim callback registry, plugin-supplied predicate or new token is introduced. A waiting result carries its actual committed native prerequisite; it is not a fabricated successful park or permission to infer completion from acceptance.

The bundled engine and core recovery hook adopt these declarations; their actual waiting/publication and explicit Resume handoff are documented below. Declaration tests alone do not prove native/host composition. Without a live workflow control core cannot invoke them. No declaration starts a provider turn, recovery timer or native write on its own.

Awaited native workflow publication

The existing policyContext.WorkflowEmitter takes (update, expectedGeneration?) and returns a promise of {status:'created', recovery}, {status:'updated'} or {status:'stale'}. Recovery is the canonical native Pick<WorkflowRecoveryAdmission, 'workflowGeneration'|'recoveryAdmissionVersion'> from elowen-db, not a copied token or generation inferred from wf.run. Boot hooks.emit uses this same emitter contract, not a separate receipt channel.

Only initial native insert omits expected generation. Its accepted result must include proof read through existing recoverableWorkflow; missing proof throws. The engine awaits that created proof and journals native metadata BEFORE node access preparation or dispatch. A later snapshot captures expected generation when queued, not when an asynchronous publication callback eventually runs. Accepted fenced updates return updated, including terminal updates without a running-only reread. Refused native writes return stale and publish no update; stale is never a persistence acknowledgement that releases node execution. Owner/channel forwarding awaits and RETURNS the same result through existing publishWorkflowUpdate, with no new BrainEvent, reader or publisher registry.

The bundled producer/engine and boot hook implement this awaited publication contract. Host/native caller composition remains separately required; see the engine and explicit Resume ordering below.

Native engine waiting and publication ordering

plugins/subagent/lib/workflow.mjs awaits the first created native publication receipt and journals its actual recovery metadata before preparing nodes. Snapshot publication captures its expected generation WHEN queued, including Stop before that first receipt; native rejection publishes nothing. Boot reconstruction adopts host-provided native metadata and does not reset or charge a dispatch budget. A stale terminal cancellation cannot publish completion over a newer explicit Resume. Completion still uses the awaited unified delegationCompletionEmitter outcome and transactional parent journal ACK.

resumeWaitingNode matches workflow/origin/call/native generation, the held node, actual wait id and claim version, then reserves that node synchronously before yielding. It adopts a newer native admission version after post-dispatch repark without inventing a new workflow generation. Waiting remains running/nonterminal with its committed prerequisite: dependents block, independent nodes proceed, and no whole-workflow completion is emitted for that wait.

A planned-only child's channel and planned session survive boot. Fresh reservation is journaled and natively snapshotted BEFORE async access/model/artifact preparation. Targeted and boot missing-child recovery pass startFresh only through the existing daemon-local continueNode; empty refuses rather than bypassing that owner. WorkflowResumeRecovery strips onEvent, fallbackPrompt and startFresh from the canonical native admission object, forwarding them as separate host arguments alongside the actual current-connection predicate. The live PluginContext caller uses one canonical input/result and retains origin AsyncResource binding; its preflight is data-only, never a public fresh callback or connection predicate. Dependency-reference failures preserve their original Error.cause.

After actual dependency preparation or the original node operation fails with typed LOCAL_TARGET_OFFLINE/LOCAL_EXECUTION_LOST, the engine reports computerUnavailable: true on the SAME original canonical admission/reference, planned child, prompt and event callback. The shared classifier follows native Error.cause wrappers but stops at another coded policy/workdir/unsupported failure. It neither fabricates a claim nor retries getRun after reconnect.

Only waiting with a committed native prerequisite keeps this node nonterminal. An answered/continued/empty result or failed park becomes an explicit node error, never accepted output or permission to replay. Native reproof/park/repark owns identity, current access and actual persistence; that park-only arm must not dispatch or charge even when reconnected. Live input/RPC contains no startFresh. Stop and generation fences before/after the awaited park prevent late refusal or completion from recreating a terminal journal or overwriting a newer run.

These are engine/publication contracts, not proof of assembled host/native placement. Focused engine tests exercise real dependency wrappers and the original operation with a controlled native park boundary; native first-live/facade/provider composition remains its owners' integration check. Ordinary configured runner, skill-review and startup-failure placement are unchanged.

Runner default and rollback

runtime.subagentRunnerEnabled is true by default. Explicit false rolls ordinary delegated execution back to the in-process path without redeployment; optional skill reviews skip instead. Ordinary recovery, subagent and parity e2e suites test the default forked path and assert runner use unconditionally; they no longer read an environment switch. The ELOWEN_SUBAGENT_RUNNER switch and duplicate :runner npm variants are retired. Use test:e2e:subagent, test:e2e:recovery, test:e2e:parity, test:e2e:parity:mcp and test:e2e:parity:mcp:deferred; test:e2e:parity checks runner parity against its stored baseline. The fingerprints pin current persona templates, the shared whole-catalog deferral policy and typed context records. Native tools outside the locked core set remain discoverable in the deferred catalog; model-only ShowView stays directly declared. The two-tool and twelve-tool MCP variants both defer their bridged names when the complete unresolved catalog exceeds the threshold, and assert that every expected name reaches the child. A deliberate persona, catalog or context-envelope change may migrate these expectations only after its source change explains the exact delta and a same-build fork proves byte-identical parent system, tool and message prefixes through the real provider request capture seam. A new capture alone does not establish fork cache parity.

Finite runner startup and strict policy IPC

The daemon's SAME required RetryPolicySource reaches SubagentRunnerHostDeps through the pool and bootstrap's late-bound getter. Reading before construction fails rather than supplying a default. Host startup subscribes before the async initial read and retains the newer revision if a commit races that snapshot. Private RunnerBootMessage.retryPolicy is required; canonical validation freezes boot and retryPolicyChanged values. Existing build handshake refuses cross-build pairs. The runner passes an IPC-only mirror implementing RetryPolicySource to buildBrainCore(role: 'runner'); it never reads a database/default policy instead. Strictly older/duplicate revisions cannot rewind the mirror, and shutdown/reset removes subscriptions. Reads and subscription after mirror disposal reject.

One SubagentRunnerHost is one physical fork/handshake attempt. The existing pool owns the serialized canonical request-mode startup episode with original plus maxRetries, one pinned policy revision, deterministic exponential timing and 60000 ms cap. It captures build identity once for replacement hosts. The former private 60-second boot cooldown is removed; the existing 60-second individual boot deadline and independent sizing/growth controls are NOT another retry budget. Application-ready startup ends the episode; a later real capacity request is new work. Failed/exhausted/cancelled startup stays terminal for that pool/target/build across new calls, replacement hosts, reset and mode toggles. New construction for a different target/build is an explicit lifecycle, not a hidden reset/probe.

startupRetry: RetryProgress | null is part of the existing pool health projection. spawnFailure clears only on actual successful startup, never a mode toggle/reset; health carries typed category/state, not diagnostic prose, paths or private build ids. Reset aborts pending startup and tears down host policy subscriptions. Native generation checks before fork and after ready reject callbacks from a replaced lifetime, including a pending configuration/MCP snapshot. No accepted turn is replayed, no daemon fallback is introduced and this finite startup is not the unlimited computer/platform transport exception.

Actual consumers are daemon pool construction, runner boot/private policy updates and health. With no requested capacity there is no startup episode; existing pool maintenance and OS supervision retain their owners. Policy work is one source subscription per live host plus increasing IPC updates, with no new poller. Focused fake-IPC/socket and ephemeral-PostgreSQL component tests do not establish an assembled daemon/provider run, native workflow receipt adoption or physical device acceptance. See worker phase evidence for the separate privileged connection boundary.

Runner protocol and lifecycle boundaries

CLI drill-in and the web's read-only child view share BrainService.tapSessionSnapshot and the existing runner tap / tap-event / untap protocol. The runner captures its model identity, durable history and live replay, then forwards text, reasoning and tool events immediately while subscribed. It calls the snapshot seam with includeExecution:false: a viewing request has no dispatched-turn ALS authority to resolve a paired computer. The daemon merges the child's location through BrainStatusService.executionFor, alongside its existing delegation and process projections. No turn is started or borrowed for a read, and closing the view releases the tap. Without a routed runner, the same seam captures the in-daemon child directly. Full events cross IPC only while a view is subscribed; ordinary parent progress remains the narrow low-frequency stream.

The same-build heartbeat carries event-loop p99, RSS and optional daemon-private process containment. Turn, session and nested-turn counts in /health come from the pool's exact placement and route bookkeeping. The interval remains HEARTBEAT_INTERVAL_MS; before the first beat, measured load is absent. Containment revisions retain the newest kill-token state for runner-death cleanup. RunnerHost consumes these frames; no client receives the private tokens.

Nested child turns count toward placement and growth. The pool counts a nested edge only when its parent and child are both sub-agent sessions routed to the same runner. It also measures the memory of that runner's process tree; a failed measurement is logged locally with could not measure runner descendants, and the heartbeat's own RSS is kept as an incomplete figure. A transcript tap's history.before cursor is a string of 1 to 128 characters, and a tap waits at most two seconds before failing with sub-agent runner tap timed out.

DelegatedTurnRunner.supportsHostRpc() reports whether the runner has the shared reverse host-call handler. It takes no method argument. The daemon uses it for delegatedWorkflowExpansionAvailable, which controls the bundled WorkflowAddNodes briefing and tool availability. An absent capability withholds remote expansion; method-specific dispatch and authorization still use the typed RPC request. The reverse host-call methods are workflow.addNodes, elowen.api, desktop.command, plugin.applyChange and delegation.resultDelivered (src/subagent/hostRpc.ts).

A runner has no daemon applyChange of its own. A plugin change made inside a runner, such as a saved skill or an MCP server through ctx.requestReload, is forwarded to the daemon as a plugin.applyChange host RPC under the dispatched turn. The daemon validates it with the plugin-boundary schema, and the call settles only on the daemon's answer. Outside a dispatched turn the change is refused with an error rather than dropped.

SubagentRunnerPoolDeps excludes onHeartbeat, onExit, onSteerSettled, onResultDelivered, onProviderUsage, onProcessesChanged and onRunnerExited because the pool owns host callbacks. Daemon consumers attach their real sinks through attachSteerSettledSink, attachResultDeliveredSink, attachProviderUsageSink, attachProcessChangedSink and attachRunnerExitTokensSink. The narrow delegation.resultDelivered host RPC carries the committed parent session and {resultId, toolCallId, kind} list. The pool accepts it only from that session's routed runner; the daemon's existing delivery publisher re-reads durable state before fanning out the acknowledgement. BrainService.attachResultDeliveredReporter binds the runner's callback to this RPC under its existing dispatched-call context; daemon construction instead attaches the pool sink to BrainTurnRunner.onResultDelivered. The RPC answers only after publication, so the runner's persistence callback awaits it before the next provider request or terminal event; channel loss rejects it and no idle deadline or retry is added. It does not settle the dispatched call or turn a workflow node into a parent result. Without a sink, no corresponding event is forwarded; host lifecycle and containment remain active.

predictsRunnerDispatch accepts the runner's optional usable capability and an explicit enabled boolean. Pool checks supply enabled?.() ?? true; statistics use the explicit operator-disabled bit from the same captured sizing snapshot. Only an operator maximum of zero or a false switch selects daemon-local execution. A memory-derived zero capacity remains runner mode and refuses admission. SubagentDispatch and brainCore remain the real routing and workflow consumers.

RunnerStartupError preserves fork_failed, boot_timeout, build_mismatch and boot_failed for pool health and pre-turn refusal. Unexpected startup errors propagate unchanged and are not converted into availability failures or recorded as boot_failed. Only application-ready startup clears spawnFailure and ends a successful startup episode. Failed, exhausted or cancelled startup remains terminal for that pool/target/build across new delegations, replacement hosts, reset and disabled-mode toggles; the separate build-mismatch latch still blocks further forks until the daemon exits. SubagentDispatch never replays a failed turn.

RunnerHost.release waits for released, busy or failed without a deadline. Runner exit or an IPC channel already disconnected before posting resolves released and removes both request listeners. A busy or failed reply preserves pool routing. The pool uses this boundary when handing a channel back to daemon execution or retiring stale routes.

FairQueue.shift returns undefined only for an empty queue and otherwise serves one non-empty head lane, preserving producer rotation and FIFO within each lane. A missing or empty head lane violates its private invariant and throws instead of silently dropping state. The runner pool's admission queue is its consumer.

HostRpcClient.close() rejects pending calls with the fixed daemon-channel-close text and removes timers and cancellation listeners. Desktop calls retain the unknown-outcome and do-not-replay suffix. With no pending calls it does nothing. The source runner invokes it on daemon disconnect.

RUNNER_ENTRY resolves the packaged runner.js or the source runner.ts when the JavaScript sibling is absent. child_process.fork inherits the parent's TypeScript loader flags; source execution therefore needs a loader that resolves the repository's NodeNext specifiers and transforms its TypeScript syntax. SubagentRunnerHost is the real consumer, and the build handshake still refuses skew.

Optional skill review children

A personal skill review is a host-minted one-shot fork with purpose skill-review, dispatched through the existing delegated request, runner protocol and pool. The purpose is internal authority, not a Delegate tool argument. The request carries the captured settled source leaf, exact last request/provider snapshot and pinned connection/generation across typed IPC. The registry Skills plugin is the consumer of the host-owned review(directive) closure, not a second dispatch API.

Unlike ordinary delegated work, this optional purpose is runner-only and opportunistic. A disabled, full, unavailable or incompatible pool returns runner-unavailable before inference. It does not enter a queue ahead of interactive requests and never falls back into the daemon. Expensive history composition and inference belong to the runner; per-account counter/proposal/file operations stay with the authoritative plugin owner. Before dispatch the live host callback repeats authorization and the exact connection's weekly quota guard, even if admission had passed earlier.

Tools retain their source declarations while all direct and nested execution is denied. Provider-hosted execution outside that gate skips. Only the full final parent output when needed and the single review directive extend the request; generic fork briefing, turn context and automatic recall are suppressed. A missing/divergent exact source or unsupported source shape skips without a fresh-prompt or helper-model request.

Runner error frames carry optional skillReviewSkipReason only when runnerTurnError receives the canonical SkillReviewSkippedError. The receiver validates its seven allowed reasons and reconstructs that typed error after releasing the in-flight slot. The host closure then returns a skipped result and preserves cadence. Ordinary provider failures remain errors; a matching message or untyped reason property never becomes a skip.

The child has its own journal, activity, transport identity and provider state and returns text only to the plugin closure. It does not deliver a result into its parent, name the child through inference, notify, curate memory or recursively learn skills. The parent's next user turn can start while review is pending. The settlement signal and shutdown cancel the real child; an interrupted optional review is not replayed after restart. Existing recording charges the author exactly once with origin internal, retaining the captured connection.

Personal skill learning defines the version-2 callback, skip union, cadence, live write budget, supported source shapes and provider/IPC/concurrency evidence. Capture and dispatch use one source-shape gate for Anthropic Messages, Codex Responses, OpenAI Responses and OpenAI Completions. Every non-history wire field and the inherited prefix remain the parent's; hosted tools and stateful Responses deltas still skip.

Memory-dreaming children

A memory-dreaming run is a fresh delegated child of one account's own conversation, created by the host and run through the same delegated path as any other child; Memory dreaming has the whole mechanism. Two parts of the delegated contract exist for it.

DelegatedExecutionScope.dream ({ runId, accountUserId }) is the durable, host-minted marker. Only the host sets it: the Delegate tool and every plugin path build a child's scope field by field from an access descriptor with no such input, and promoting a child mints a scope from live access, so no model input can name a dream. A malformed value fails the whole scope closed, like every other field, and it survives a continuation's narrowing. It is not authority over the account: the child gets no addressable conversation, and the memory tools act for the account only because the identity this scope produced names it.

IdentityResolver.forDelegatedTurn grants the memory identity (elowenUserId) only when the scope carries a dream AND the parent session proves it is that account's own top-level conversation: it belongs to the named account, it is not a continuation, it is not itself a delegated child, and it is not a brain-ch-* room. Every ordinary delegated child still gets no memory identity, and the proof is re-derived on every rehydrate rather than stored. delegatedChannelSendOpts always forwards the request's parent id into this resolver, including remote fencing, and the continuation dispatch does the same. Callers do not need a separate identity wrapper to preserve the dream's memory authority.

dreamToolDenial in src/brain/session/capabilities.ts is the child's tool-name boundary, a sibling of planWriteDenial checked before gatePermissions' early return. It allows the memory tools (personal scope), a read-only shell, a read-only ElowenApi credential, Project (whose mutations refuse a read-only origin), read-only DocsSearch, the transport tools ToolSearch, exec and wait, and plugin tools their manifests declare read-only; everything else is refused. An ask resolves like every other unattended turn's, through the account's unattendedAsks setting, while a shell ask always refuses because the clamp already allows every parsed non-destructive command. The service captures the shared buildReadOnlyBoundary shell clamp. CapabilitySpec.dreamAuthority reads current account grants and permission settings per call through the spawner; the captured scope remains an independent ceiling. Dreams execute in process via BrainService.runDelegatedTurn, rather than through the runner pool.