A small hybrid file/SQLite DAG workflow manager. User payloads stay inspectable in input/, output/, and jobs/<id>/, while high-churn scheduler state is stored transactionally in .mwf/state.sqlite3. Each node has one main task, optional fallbacks, explicit starter jobs, and APIRouter-style node modules.
The output/ folder is not only a place for the final value. A well-designed
node writes both the durable result that can be reused or reformatted and
user-owned provenance that makes the result easier to debug and improve. Useful
provenance includes the relevant inputs, algorithm/model/tool choice, attempt or
fallback, validation evidence, and important parameters. Framework diagnostics in .mwf/state.sqlite3 explain scheduler behavior and are shown by mwf inspect; they do not replace domain provenance written by the project.
See HOW_TO_TEST.md for the authoritative release-test order, including separate autostart-cycle processes and the localhost HTTP performance matrix.
See DESIGN.md for design and code-architecture recommendations,
command workflows, provenance guidance, and runnable examples covering adapted
src/ + utils/ pipelines, five common agentic patterns, a database change
manager, and a Pygame state machine.
- Simultaneously runnable API nodes now receive a shared controller-pump vector.
Every node is guaranteed one pump; the host-bounded remainder is allocated by
marginal controller-load reduction. On the 16-logical-processor explode
shape, 21 pumps are allocated as
1,2,3,2,4,2,2,1,2,2while every node's configured concurrency remains exact and unchanged. - API-fiber trace, output, and input-forwarding events are generation-fenced and enqueued asynchronously into the existing ordered SQLite group commit. Each attempt flushes its event futures before fallback or terminal publication, so provenance remains durable-before-terminal without one SQLite round trip per observability record.
- Not-yet-executing priority-20 checkpoint snapshots coalesce per job attempt. The latest checkpoint stays inspectable, timeouts remain durable, and admission, successful terminal publication, and failed terminal publication all remain in the same priority-5 runtime-critical class.
- API networking now has an explicit backend
NetworkManager: one process-wide event loop owns persistent HTTPX client shards and all socket I/O. Node fibers enqueue lightweight requests; dense cross-thread submissions are coalesced before asyncio task creation instead of callingrun_coroutine_threadsafeonce per request. Existingshared_http_transportapplication code remains unchanged. - Network-manager state is aggregated in memory and bulk-upserted into the new SQLite
network_statetable at most every two seconds. This observability path is low-priority and non-fatal. SQLite schema version is 4. - Adds the requested 22-node skew A/B benchmark: two 2,000-job nodes plus twenty 100-job nodes with 512 proportionally allocated API slots. In the observed unlimited-bandwidth H2 sample, the manager improved runner throughput ~6.5%, durable workflow throughput ~6.4%, and the durable big:small ratio from 11.69:1 to 13.16:1. See
NETWORK_MANAGER_ARCHITECTURE_056.md. - Retains the 0.5.5 queue-scan and dense-refill optimizations: refreshable queues use monotonic direct rowid range scans without a temporary ORDER BY tree, queue hints are bounded, and dense API nodes avoid repeated tiny durable refills.
- The localhost delay server paces the final/only chunk correctly and no longer awaits H2 socket backpressure while holding the protocol-state lock. A 4 KiB response at 4 KiB/s now takes about one second.
- High-concurrency API admission records the first valid main-task
task_startedevent in the existing grouped execution-claim transaction instead of submitting one extra synchronous SQLite mutation per job. Malformed jobs with missing required parameters retain the old trace semantics and are not falsely marked task-started. - Wide DAG finalization now bulk-reads node status, skips already-terminal and in-flight sibling components, and gives component execution one owner for
RUNNING/DONEpublication. This removes repeated sibling status rewrites as fan-out width grows into tens of nodes. - HTTP/1.1 shared transport now uses elastic 16-connection client shards by default; HTTP/2 keeps its stream-per-connection behavior unchanged. The localhost benchmark measured about 3.55x higher H1 runner throughput at 512 concurrent requests versus the previous 100-connection shards.
include_router()retains router objects instead of remembering onlyid(router), preventing CPython object-id reuse from silently skipping short-lived programmatically generated routers in wide fan-outs.- Adds a real localhost HTTP delay/throttle service and a three-axis fan-out benchmark over concurrency, per-response transfer rate, and fan-out node count. See
HOW_TO_TEST.mdandHTTP_FANOUT_BENCHMARKS_054.md. - Retains all 0.5.3 Hoeflein live-pump, clean failure/join, EMFILE, threaded prefetch, FD-limit and
resumefrom ... refuseafter ...behavior.
- Hoeflein components now keep every ordinary threaded/API member attached to a live, event-driven queue pump for the lifetime of the component. Temporary empty queues no longer tear down a member and turn internal feedback into a mini-DAG queue; explicit
wait_fornodes remain phase-gated. - Threaded source advancement no longer holds peer workers behind payload I/O, and payload-loader failures such as
EMFILEpropagate as their original exception instead of creating a phantomNonejob. - Component failure is published only after all already-started member runners join. Output-backed terminal states are reconciled first and any remaining abandoned
runningleases are marked failed, so a failed Hoeflein component cannot retain ghost running jobs. - Retains 0.5.2
resumefrom ... refuseafter ..., automatic FD-limit raising, and bounded threaded payload prefetch.
mwf resumefrom START refuseafter BOUNDARYnow has the same inclusive Hoeflein-component admission boundary asrunfrom ... refuseafter ...: the named boundary component may finish or fail, no new later component is admitted afterward, already-running parallel components are joined, and queued later work is retained for a future resume.- Retains the 0.5.1 threaded payload-prefetch and automatic open-file-limit improvements.
mwf resetandmwf resetfromnow perform the exact fresh preparation used byrunandrunfromwithout starting a scheduler or executing tasks.mwf cleanfromdeletes all jobs and generated output in the selected Hoeflein component and quotient-DAG descendants;mwf wipefromadditionally deletes their inputs.cleanandwipeapply the same semantics to the current component. All six destructive preparation commands require a typed confirmation unless--yesis supplied, and all support--dry-run.AGENT.md,examples/README.md, and the newexamples/agent_reference_architectureprovide a standard project layout and a complete API/HTTP, fallback, transactional fan-out, durable fan-in, and bounded Hoeflein-component reference design.mwf runfrom START refuseafter STOPperforms the same full freshening as an ordinaryrunfrom, but stops admitting new Hoeflein components as soon as STOP's component completes or fails. Components already running at that instant are joined; downstream jobs remain queued for a later command.- Fresh and destructive commands now clear affected job trace journals by
default. Add
--keeptracetorun,runfrom, selected-job runs,reset,clean, orwipeto retain the prior transcript. resumealways preserves the selected current component's trace. Defaultresumefrompreserves its start component and clears descendant traces;resumefrom --keeptraceretains the entire selected descendant history.- Preserved event journals survive node copy/paste and can remain attached to a
temporarily deleted job identity. When a recreated job keeps its node/job ID
but receives a different parent, producer component, or job kind, MWF appends
and renders a separate
ORIGIN CHANGEDsubsection before the new execution history. - Repeated CLI/storage churn no longer risks a same-thread SQLite registry deadlock when cyclic garbage collection invokes an old storage finalizer during connection setup.
- API nodes use fixed-limit adaptive admission sharding by default. Dense
nodes that become runnable together receive one shared pump vector. Every API
node is guaranteed one pump. The total is bounded by the smaller of the nodes'
isolated benefit ceilings and
max(12, logical_processors + 5); remaining pumps are assigned by marginal benefitn / (p * (p + 1)), wherenis the declared concurrency andpis the node's current pump count. On the supplied 16-logical-processor explode workload this gives 21 pumps across ten handlers._LaneCoordinatorkeeps each node's lane-concurrency sum exactly equal to its declared limit, so controller sharding never reduces or increases job concurrency. Already-running pumps remain charged when later DAG branches become ready, preventing successive waves from each consuming a fresh host budget. Explicitevent,balanced,elastic, andlanes:Nstrategies remain available for controlled comparisons. - Simultaneous Hoeflein claim bursts are combined into one grouped SQLite operation. The mutation writer also caps ordinary claim transactions at 192 job rows, so a multi-thousand-job admission wave cannot trap urgent terminal publication behind one non-preemptible transaction.
- Refreshable job sources reserve row IDs under a short lock while payload reads and claims overlap. Windows share a bounded payload-read pool on Windows, avoiding a fresh thread pool for every node and every admission slice.
benchmarks/compare_job_loading_models.pycompares fixed ladders, source-aware windows, elastic loading, and two/three/four-lane models on the supplied explode shape plus uneven 11k–12k-job graphs. A model is eligible only with no missing monitor rows, no final residue, and bounded output-to-terminal p95/max.- A bounded regression test now samples SQLite during uneven high-concurrency execution and verifies exact output-write to durable-terminal latency. A separate 27-job-tail test prevents a small final queue from waiting for the next admission plateau.
- Durable
job_eventsare now the workflow state stream. In-process schedulers receive commit callbacks immediately, while second-terminal commands and restart control use coalesced loopback wakeups plus anevent_idcursor. A five-second timeout remains only as a defensive fallback; normal lifecycle progress is no longer discovered by frequent status polling. mwf topadds an event-driven htop-style dashboard with per-node queue/run counts, effective limits, starts/finishes per second, queue and terminal p95 latency, recent lifecycle events, process RSS/thread data, SQLite/WAL size, and the active process's mutation-writer backlog and batch diagnostics.- The former production API startup strategy was
single. It remains available aseventfor controlled comparisons, but shared-budget adaptive sharding is the default after single-controller scaling tests exposed a large throughput penalty at high declared concurrency. - Retry/fallback inspection moved from
mwf inspect NODE filtertomwf filter NODE;mwf filter NODE stage Xshows terminal failures at the final stage or failures at X that succeeded at X+1.
- Dense API sources now admit at most 64 jobs per scheduler slice and service completed futures every 16 starts. Fast provider responses can therefore publish output and terminal state while a large Hoeflein component is still filling, instead of waiting behind a 256/512/1024 start wave.
- Supervised API attempt metadata is generation/execution fenced, grouped by node, deduplicated within each writer batch, and written asynchronously below terminal priority. Startup inspection writes no longer serialize every job, and terminal rows remain the monitor source of truth.
- Active-restart supervision polls one project revision row. It materializes live execution leases only after an actual restart request, rather than rereading every active job every 50 ms at high concurrency.
- Hot filesystem and SQLite paths cache canonical project/node directories and avoid repeated path resolution for validated node names and integer job IDs. This removes substantial per-job queueing overhead without weakening path traversal validation or generation fences.
benchmarks/reproduce_explode_ghost.pycopies the ten-handlerpdftostructureddataexplode component and uses a variable-latency mock HTTP provider to measure provider completion, durable output, and monitor-visible terminal state separately.
- Terminal job publication now enters the existing priority SQLite writer directly. The writer groups related terminal records for at most 5 ms and applies one bulk lease-fenced update/event operation; the redundant terminal daemon queue has been removed.
- A node or Hoeflein-component failure stops new admission but waits for every already-started threaded, process, or API job to reach its terminal boundary. Failure handling no longer scans output files or performs a special terminal drain before marking the component failed.
mwf resume NODEandmwf resumefrom STARTfirst reconcile terminaloutput.jsonfiles against stalerunningrows, cross the SQLite durability barrier, and only then generation-fence and requeue the remaining unsuccessful work. This preserves handlers that finished before a process interruption.mwf restart NODErestarts every live-running and failed/cancelled job in the node's active Hoeflein component (a DAG node is a singleton component).mwf restart NODE failedlimits that component-wide selection to failed/cancelled jobs. The explicitjobandjobsforms remain available.- A waiting node now requires every selected peer to have zero queued, running, and failed jobs. The old queued-only cycle bootstrap has been removed, so the declared gate is never bypassed.
- SQLite state handling is separated into connection/mutation, schema,
advisory-lock, and transfer modules. Job creation, batching, querying,
cleanup, execution claims, terminal publication, and restart recovery are
likewise isolated behind the existing
FileStoragefacade. - Terminal job updates are published by a dedicated fixed-cadence coordinator
and use batched conditional SQL updates/events. Hoeflein components also
reconcile terminal
output.jsonfiles every 250 ms, recovering a completed handler if its final SQLite mutation was interrupted. - A node failure urgently flushes all already-written terminal outcomes before
the shared Hoeflein stop signal is raised. Sibling queue admission stops at
that point, and API jobs preclaimed but not started are returned to
queuedfor a safemwf resume. - Multi-concern production modules over 500 lines were split into focused facades and implementation files. The remaining file above 500 lines is the cohesive cooperative fiber runtime.
- Refreshable API admission then adapted across a wider range. It starts at 64, drops to 16 after a partial or empty pull for sparse/trickling queues, and grows geometrically to 1024 while pulls remain full for dense fixed queues.
- At that release, a 2,000-job dense queue reached its final partial pull in six admission
rounds (
64, 128, 256, 512, 1024, 16) instead of repeatedly claiming fixed groups of 64. - Terminal completion batching remains independent of the API fiber scheduler,
so adaptive claims cannot prevent output-backed
doneupdates from entering the higher-priority SQLite mutation lane.
- Terminal job status/event commits and execution claims now share runtime- critical priority 5. Success, failure, and admission therefore enter one FIFO class; bounded claim batches and cooperative callback servicing supply the fairness without making one outcome type outrank another.
- A monitor-shaped regression verifies that a large supervised API node reports every completion from SQLite and leaves no queued/running residue while its claims and terminal outcomes use that same priority.
- Fresh Hoeflein cleanup now reads producer provenance in one SQLite snapshot, deletes selected-producer jobs in node-sized batches, requeues retained jobs in one transaction per node, and removes independent job artifacts with bounded parallelism on Windows. Large components begin pumping instead of spending tens of seconds in per-job preparation.
- API fiber admission no longer scans every outstanding future and sleeper after each 64-job burst. Future deadlines use a heap, while restart/cancellation checks run at the configured polling cadence. Large typed nodes therefore progress through claim, watchdog setup, and HTTP dispatch at the same rate as small nodes.
- Threaded and API queue pumps load one metadata snapshot per 64-job burst and prefetch the independent payload files before execution claims. The grouped writer now receives dense claim bursts instead of claims separated by per-job metadata queries and file reads.
- A one-child route stages its tiny input before entering SQLite, then allocates the ID and publishes the payload, row, event, and node state in one mutation. Queue publication, local threaded execution, and API execution use separate writer priorities, and one commit never absorbs lower-priority consumer work.
- Concurrent execution claims and terminal updates coalesce by node. This keeps live API startup and large completion waves from paying one savepoint and one scheduler round trip per job while preserving an independent restart lease and outcome for every job.
- Terminal job publication releases the generation fence before waiting for the grouped SQLite commit, then uses a lease-conditional terminal update. A large completion wave no longer retains one lock-file handle per yielding fiber.
- Newly reserved, unpublished job inputs use a direct exclusive create instead
of a temporary file plus rename. Short local routers can also group several
file/queue mutations under
with ctx.side_effects():to reuse one restart fence without holding it across a network request.
- Live Hoeflein components now wake on committed queue changes and completed node pumps. The one-second poll is only a cross-process recovery fallback, so a handler can start immediately when a running router creates work.
- Single-job routing no longer acquires and releases a SQLite advisory lock for every handoff. Auto-ID reservation and prepared-job publication use grouped commits; publication combines the job row, creation event, idempotency row, node status, and sequence advancement.
- Job start, completion, event, and checkpoint mutations share the same short group-commit lane. Bursts of completions no longer become one durable commit per state field per job.
- Active-restart detection moved from one SQLite query per waiting job per poll to one supervisor query for all active leases. Exact side-effect and final publication fences remain per job.
- API fibers receive future-completion callbacks and O(1) ready-queue pops. Progressive completion waves no longer rescan all outstanding futures or shift a growing list on every resume.
- The framework HTTP transport now supports elastic connection sharding with
http2=andstreams_per_connection=. These settings choose a client for each in-flight request; they never cap job admission. Per-nodemax_threadsremains the concurrency control.
from micro_workflow_manager import configure_shared_http_transport
configure_shared_http_transport(
http2=True,
streams_per_connection=80,
)The first 80 simultaneous requests use the first HTTP/2 client/connection, the next 80 cause a second client to be created, and so on. Completed slots are reused. For HTTP/1.1, the same option controls the requests assigned to each client pool. It is a transport-shaping value, not a semaphore or workflow-wide limit.
mwf clean,mwf reset, andmwf wipenow treat a Hoeflein component as the indivisible cleanup unit. Naming one member expands to every member of that component; DAG nodes remain singleton components.- Nodes may declare an intra-component waiting gate with
waiting=Trueandwait_for=.... Queued jobs remain durably queued, but the node displays aswaitingand no new node pump starts until every selected peer has zero queued, running, and failed jobs. A pump that already started continues normally. wait_for=Nonewithwaiting=Truemeans all other vertices in the component. A list selects a subset. Waiting targets outside the component are rejected.- Waiting on a singleton DAG component is allowed but has no effect; CLI loading prints a reminder that ordinary DAG predecessor readiness is the available queue-independent mechanism.
- Waiting declarations are strict. A mutually waiting set with blocked work on every side remains waiting until a restart, resume, or producer action clears the declared queued/running/failed conditions.
from micro_workflow_manager import NodeRouter
router = NodeRouter(
"router",
runner="threaded",
waiting=True,
wait_for=["worker_a", "worker_b"],
)
# Equivalent fluent forms:
# router.wait_for_nodes("worker_a", "worker_b")
# router.wait_for_component() # every other component memberWaiting is a node-pump admission rule, not a job-status rewrite. Jobs stay
queued in SQLite so reset/resume semantics remain unchanged; mwf monitor
shows the node lifecycle state as waiting and includes waiting_on in JSON.
- MWF now owns one process-wide pooled
httpx.AsyncClient. Synchronous API tasks callshared_http_transportand suspend cooperatively on the existing fiber runtime; projects no longer need to embed an asyncio thread/client bridge. - Framework HTTP waits are explicit scheduler states. A bounded live transport request suspends checkpoint-progress expiry, while the task total timeout and the HTTP transport timeout remain active. This prevents synchronized watchdog cancellation waves when hundreds of model requests are legitimately waiting.
- Timed
concurrent.futures.Future.result(timeout=...)retains its periodic timeout behavior inside fibers, so heartbeat loops continue to run. - Fiber admission occurs in bounded bursts with scheduler servicing between bursts. Starting thousands of jobs cannot starve earlier fibers for an entire checkpoint window.
ctx.sleep()is cooperative in API fibers. Per-node API limits may be set into the thousands; there is no workflow-wide aggregate API cap.
from micro_workflow_manager import shared_http_transport
payload = shared_http_transport.post_json(
"https://api.example.com/v1/chat",
headers={"Authorization": "Bearer ..."},
json={"model": "...", "messages": [...]},
timeout=(30, 1800),
heartbeat_callback=lambda elapsed: ctx.checkpoint(
f"model request active for {elapsed:.0f}s", timeout=90
),
heartbeat_interval=15,
wait_name="model request",
)The transport uses httpx connection pooling and integrates directly with the
scheduler watchdog. In 0.3.17 it may own several automatically selected client
shards. The checkpoint lease is suspended only while the bounded network
operation is active.
- API-runner node pumps now refill from jobs committed after the pump starts. A typed handler that begins with one routed job can grow toward its configured concurrency while that first API request is still running, instead of waiting for the initial static queue snapshot to finish.
- Refreshable queued-job sources follow SQLite row insertion order, so concurrent batch producers that reserve lower job IDs but commit after a higher range are still discovered. Non-API runners retain the existing snapshot behavior and deterministic job-ID ordering.
- Added component and CLI
--monitorregressions for gradual high-fanout routing, including the former state where a handler displayed hundreds queued but only one running.
- Hoeflein components now use live node pumps. While one member is still processing its queue, the scheduler polls idle sibling queues and starts them as soon as internal component jobs appear. A fast handler can drain and be restarted repeatedly while a long-running router continues producing work.
- Monitor rows now derive their display state from actual per-node job counts. Queued component work is not reported as running before execution, idle handlers remain queued while a router starts, and a handler with active jobs is shown running even if a concurrent component refresh wrote a broader node lifecycle state.
- The adaptive threaded runner now exits after the first worker failure even
when its lazy source still contains unclaimed jobs. This fixes active runs
that remained stuck at
status=running,running=0, with queued jobs left. - Windows safe-path validation now treats ordinary and extended-length
(
\\?\\) spellings of the same resolved path as equivalent while retaining the same descendant-only security check. - Fresh producer-scoped cleanup rewinds the quiescent target's job allocator so deterministically recreated jobs keep their previous tail IDs.
- Added a high-fanout batch API:
NodeHandle.add_many,NodeInputFileSystem.add_jobs, andNodeInputFileSystem.write_jsons. One downstream job is still created per parameter object, but payload files and SQLite metadata are registered in batches rather than through one global lock and transaction sequence per object. - Added transactional per-node job-ID sequences. Existing 0.3.9 databases are
migrated in place by initializing each sequence to
MAX(job_id) + 1; existing jobs, statuses, events, and payload folders are preserved. - Batch registration reserves IDs briefly, prepares disjoint payload files outside the database transaction, and commits jobs, creation events, idempotency rows, and node status in one transaction. Concurrent producers are safe and duplicate idempotency keys resolve to the existing jobs.
- Deterministic
overwrite=Truenode-input batches use atomic replacement without a global advisory lock.overwrite=Falseretains locked unique-name allocation. - Added separate-component high-fanout regressions proving that a producer can queue hundreds of jobs for a downstream Hoeflein component without autostarting it or merging the two components.
- Framework-created API, threaded-runner, Hoeflein node, scheduler-supervisor, and inline monitor threads now close their per-thread SQLite connection when the thread or job finishes. Dead thread identifiers cannot inherit an older connection if Python later reuses the numeric thread ID.
- Same-process SQLite writers are serialized before
BEGIN IMMEDIATE. SQLite still provides cross-process coordination, but hundreds of local workers no longer enterbusy_timeouttogether. Commit failures now always roll back so a persistent connection cannot retain a write transaction and poison later scheduler rounds. - Checkpoint runtime persistence is one conditional
UPDATEinstead of a database advisory-lock acquire, runtime update, and advisory-lock release. Late checkpoints from a timed-out or restarted watch still cannot overwrite its terminal runtime state. - Generation/restart-fenced file mutations now use per-job operating-system file
locks in
.mwf/execution-fences/. This preserves second-terminalrestartordering without writing SQLite advisory-lock rows around every file write. - Repeated high-concurrency API rounds and repeated CLI
run --monitorrounds now have regression coverage for connection cleanup, runtime writes, file fencing, database integrity, and progressive slowdown.
mwf run NODEis again a true fresh run even when NODE's jobs were created by an external predecessor. Every remaining job in the explicitly selected Hoeflein start component is requeued and its generated output/files are cleared before scheduling.mwf runfrom NODEapplies that full reset to the selected start component, then keeps producer-scoped cleanup for descendant merge components. Work from unselected incoming branches is still preserved exactly as before.mwf resumefrom NODEautomatically generation-fences and requeues failed, cancelled, and abandoned-running jobs throughout the selected descendant set. A separatemwf restartstep is not required after a completed partial run.mwf restartis now strictly a second-terminal control for a liverun,runfrom,resume, orresumefromsequence. It may replace a running attempt or requeue a failed/cancelled job owned by that active sequence, but it no longer edits failed jobs after the sequence has ended. Useresumeorresumefromfor post-failure continuation.- Fresh-run, branch-preservation, resumefrom, restart, and final run-state
behavior are covered by repeated CLI regression tests using
--monitor.
- SQLite advisory locks now record the owning host and process and immediately
reclaim rows whose local owner process has exited. An interrupted or killed
mwf threads/mwf runcommand therefore cannot strandthread-overridesbehind its old 300-second lease and make the next run time out after 120 seconds. - A live local owner is no longer displaced merely because a long critical section exceeded the nominal lease. Unknown or remote owners still use the lease as the safe fallback.
- Run startup binds temporary thread overrides before publishing
run.jsonas running.mwf threadsserializes active-run discovery with that startup, so a concurrent command cannot accidentally scope its value to the following run. Run completion publishes its terminal state before best-effort override cleanup, so an override cleanup problem cannot leave a completed run falsely recorded as active. mwf threads NODE VALUEprints a resource warning above 256 in-flight jobs. CLI restart/timeout supervision can use roughly one controller and one handler thread per active job, so settings such as 750 can put severe pressure on Windows thread, memory, SQLite, socket, and API connection limits.
- Scheduling now uses Hoeflein components. Let
A ⊆ Ebe the graph edges explicitly used withautostart=Truein node behavior code. MWF constructs the augmented directed graphG_H = (V, E ∪ {(v, u) : (u, v) ∈ A})and takes its strongly connected components. The quotient keeps the direction of the original graph edges and is the scheduler DAG,HDAG(G). - Naming any node in a multi-node Hoeflein component with
mwf run,mwf runfrom,mwf resume, ormwf resumefromselects the whole component. MWF prints a reminder before execution. Every original graph edge whose ends lie in the same component is automatically treated as component-autostart, even when that particularadd(...)call omitsautostart=True. - A Hoeflein component is one lifecycle unit: its nodes enter running together, become done together when quiescent, and become failed together if any job fails. Live node pumps stop accepting newly discovered sibling work after the first failure, and the component is published failed while active pumps wind down.
- Generated jobs retain their immediate parent node/job and also record a stable
producer-component identity plus whether they are a
dagorcomponentjob. Fresh runs remove only jobs produced by the selected Hoeflein components. Jobs produced by unselected branches are preserved with their status, input, output, returned files, and provenance. mwf runfrom Amay process A's branch through a later merge component while another incoming branch is unfinished. A latermwf runfrom Bremoves and rebuilds only B-produced work; it does not delete A-produced jobs already in the shared descendant. By contrast, the starting component must have every external predecessor complete. ForA -> CandB autostarts C, the quotient isA -> {B,C}, somwf run Bis refused until A has completed.mwf inspect NODE job IDnow shows the producer component and the job kind.--planexplains component selection and producer-scoped cleanup.
mwf run NODE --monitorandmwf runfrom NODE --monitorprint the full timestamped monitor dashboard in the execution terminal. Inline snapshots do not clear task output;--monitor-intervalcontrols their cadence. The same option is available on resume forms and selected-job runs.- A terminal monitor snapshot reports
active run: noneafter a sequence is done, blocked, incomplete, or failed, and separately identifies the last run. AGENT.mddefines the required testing and failure-diagnosis protocol: focused reproduction, concurrency and timeout experiments, freeze classification, repeated command use, separate-process cyclic tests, test maintenance, and the rare stubborn-issue escalation format.- Execution reporters are lifecycle-owned by the active run, preventing stale inline reporter threads or final snapshots that still describe a finished run as active.
- The synchronous execution stack is flatter. A runner worker is now the
attempt controller and invokes retry/fallback orchestration directly. For a
supervised or actively restartable attempt, it creates exactly one
abandonable
mwf-handler-*thread for the current user handler; the old worker -> attempt thread -> handler thread stack is gone. - The new
apirunner is designed for blocking network/API/I/O jobs. It fills the requested concurrency immediately and intentionally keeps the familiarmax_threadssetting, where the value means maximum in-flight API jobs.ioandnetworkare accepted aliases. mwf initcreates.mwf/state.sqlite3. Job identity and status, queue membership, node status, lifecycle events, retries/fallback diagnostics, execution generations, checkpoints, idempotency keys, default-job declarations, summary counts, and cross-process advisory locks now live in SQLite with WAL enabled.- User data remains ordinary files: node
input/, nodeoutput/, jobinput.json, joboutput.json, and returnedjobs/<id>/files/are not moved into the database. Existing 0.3.3-and-earlier metadata is imported once and removed only after it is durable in SQLite. mwf migrate --dry-runremains read-only, whiledoctor,monitor,inspect, restart/recovery, cleanup, clipboard, deployment, process running, and filter-funnel inspection preserve their previous functionality against the new state backend.- The generated Material Icon Theme settings no longer force the top-level
nodefolder to use theflowicon. Exact graph-node folder names still useflow, and unrelated user associations remain untouched.
-
When every fallback fails, the job output now records the terminal fallback error rather than the stale main-task error.
-
This makes
mwf inspect <node-name> job <id>agree with the final timeout/event that actually caused the job to fail. -
Existing task, retry, checkpoint, restart, deployment, and thread-override behavior is unchanged.
-
mwf inspect <node-name> failedprints failed job IDs, concise errors, and the appropriate resume/resumefrom command; during a live sequence it also shows the second-terminal restart form. -
Extended CLI examples no longer use a node literally named
wait; examples use neutral placeholders or simple operation names.
- Runtime
mwf threadsoverrides are scoped to one workflow run. An override configured before a run is claimed by that next run and deleted when the run finishes; an override changed from a second terminal is deleted with the active run. Stale overrides from a crashed older run are ignored. mwf restartgeneration-fenced live attempts and originally allowed offline failed/cancelled requeueing. Version 0.3.8 reserves restart for a live second-terminal sequence; post-failure continuation now belongs directly tomwf resumeandmwf resumefrom.mwf deploy setupexplicitly prompts for the SSH port when--portis not supplied.mwf initmerges Material Icon Theme settings into.vscode/settings.jsonand associates.mwfignorewith theroutingicon. Existing unrelated VS Code settings are preserved.
MWF 0.3.0 encourages node behavior files to describe their filesystem contract next to the router. A task should read like workflow logic, while reusable filesystem objects hold the stable information about where data comes from, where it is written, and which downstream node receives it.
The four standard objects are:
InputFileSystem: the current node's read-onlyinput/folder.OutputFileSystem: the current node's persistentoutput/folder.JobFileSystem: files returned by one job injobs/<id>/files/.NodeInputFileSystem: another node'sinput/folder and job-creation route.
Each declaration has a human-readable label, an optional portable base-path
template, and an encoding. NodeInputFileSystem also records the destination
node. The object is only a declaration at import time; it resolves paths lazily
when bound to a JobContext, so the same node file works on Windows and Linux.
A representative node behavior file is:
from micro_workflow_manager import (
InputFileSystem,
NodeInputFileSystem,
NodeRouter,
OutputFileSystem,
)
router = NodeRouter("add_numbers", max_threads=2)
INPUT = InputFileSystem("number input")
OUTPUT = OutputFileSystem("sum output", base="{batch}")
REVIEW_INPUT = NodeInputFileSystem(
"review",
"review input",
base="{batch}",
)
@router.task(timeout=60)
def add_numbers(ctx, batch, source_file):
# Load input through the declared filesystem contract.
numbers = INPUT.file(ctx, source_file).read_json()
ctx.checkpoint("numbers loaded", timeout=20, progress=0.25)
total = sum(numbers)
ctx.checkpoint("sum calculated", timeout=20, progress=0.75)
# Write output and carry it forward through filesystem objects.
result = OUTPUT.file(ctx, "sum.json", batch=batch)
result.write_json({"total": total})
review_copy = REVIEW_INPUT.file(ctx, "sum.json", batch=batch)
review_copy.copy_from(result, overwrite=True)
REVIEW_INPUT.add_job(
ctx,
batch=batch,
result_file=review_copy.relative_path,
)
return {"total": total}This structure is deliberate:
- Imports state the external tools and MWF concepts used by the node.
- The router and filesystem declarations state the node's execution and data contract before any task code.
- The task loads named inputs, performs domain subtasks, reports optional checkpoints, then writes and routes named outputs.
- Helper functions can accept
FileSystemEntryobjects instead of rebuilding project paths or calling low-level context methods repeatedly.
A filesystem object's base may use simple str.format placeholders:
PAGES = OutputFileSystem("rendered pages", base="{book_name}")
page = PAGES.file(ctx, "page_001.png", book_name=book_name)page is a FileSystemEntry. It is path-like, so it can be passed to most
libraries that accept str, Path, or os.PathLike, while also providing
workflow-aware methods:
page.exists()
page.read_bytes()
page.write_bytes(data)
page.copy_to(destination, overwrite=True)
page.parent.mkdir()
PAGES.files(ctx, "*.png", book_name=book_name)All relative paths are normalized to portable / form and reject absolute paths
and ... Managed writes through write_text, write_bytes, write_json,
append_text, copy_from, and delete use MWF's execution-generation guards.
This prevents a restarted or timed-out stale attempt from committing through the
framework.
The .path property and writable .open() are available for third-party
libraries that require an ordinary filesystem path. Direct writes made by such
a library cannot be rolled back or fenced for the full duration of the open
handle, so prefer the managed methods when possible and use checkpoints around
long external operations.
For a frequently used destination, a project may give its route a domain name:
class ReviewInputFileSystem(NodeInputFileSystem):
def __init__(self):
super().__init__("review", "review input")
REVIEW_INPUT = ReviewInputFileSystem()This is optional. A plainly named instance such as
REVIEW_INPUT = NodeInputFileSystem("review", "review input") is usually the
smallest and clearest form.
A complete runnable version of the simple addition example is included in
examples/filesystem_objects.
The original ctx.input_path(), ctx.write_output(), ctx.write(), and
ctx.node() methods remain supported. Filesystem objects are the recommended
client-facing architecture, not a forced migration or a second storage system.
They are thin declarations over the same hybrid storage, scheduler guards, transactions, and downstream job APIs. Payload paths remain files, while scheduler mutations use SQLite, so the declarations do not add project scans or per-file background work.
Framework-owned project state is consolidated under .mwf/:
.mwf/
project.json # graph path, stored edges, default runner, low-churn config
run.json # active/recent CLI ownership and scheduler heartbeat
threads.json # optional run-scoped node overrides and API total budget
state.sqlite3 # jobs, queue, events, checkpoints, idempotency, advisory locks
deploy/ # server setup and replaceable local deployment archive
SQLite uses WAL mode so monitor and inspect readers do not block the
scheduler's short writes. The database stores framework state only. Node
input/, node output/, each job's input.json and output.json, and returned
files remain ordinary files.
Projects from older releases are migrated automatically. Legacy .mwf root
JSON, .mwf_run.json, and .mwf_threads.json are consolidated; legacy
.mwf_locks/, queued/, idempotency/, node_state.json, job.json,
status.json, execution.json, runtime.json, events.jsonl, default-job
manifests, and job indexes are imported into SQLite when applicable. User
payload files are not rewritten. Migration is idempotent.
The generated .gitignore ignores .mwf/ and legacy runtime-only paths under
node and clipboard snapshots. Direct files in
clipboard/<node>/input/ and clipboard/<node>/output/ remain trackable.
mwf init merges Material Icon Theme settings without replacing unrelated user
settings:
{
"workbench.iconTheme": "material-icon-theme",
"material-icon-theme.files.associations": {
".mwfignore": "routing",
"graph.py": "routing"
},
"material-icon-theme.folders.associations": {
"clipboard": "archive",
"input": "input",
"output": "export",
"jobs": "tasks",
"queued": "queue",
"idempotency": "keys"
}
}The top-level node folder deliberately keeps the icon theme's native icon.
After the graph is set, MWF associates each exact graph node name with flow.
Because Material Icon Theme associations are name-based, the same mapping styles
both node/<name>/ and clipboard/<name>/. Install the Material Icon Theme VS
Code extension to see these associations.
The graph definition and the top-level node/ folders are synchronized only by
the graph command. Ordinary commands such as run, runfrom, clean, and
monitor do not silently add or remove node folders.
Set the graph the first time:
mwf graph src/graph.pyAfter editing edges or renaming, adding, or removing nodes, preview and then explicitly apply the new graph state:
mwf graph --update --dry-run
mwf graph --updatemwf graph --update uses the graph path already stored in .mwf/project.json. Relative graph
paths are stored with /, even on Windows. When reading an older or manually
edited project, MWF accepts both src/graph.py and src\graph.py, resolves the
path inside the project root, and rewrites it to the portable / form on the
next update. It creates folders for new nodes and permanently deletes folders
for nodes no longer in the graph, including their inputs, outputs, jobs, and state. Back up or move any data
you need before updating. If an ordinary command detects changed edges, missing
new folders, or stale renamed folders, it exits with an instruction to run the
update and leaves the disk unchanged.
A leftover node_behavior/*.py file whose router name is no longer in the graph
is ignored; importing the project will not recreate that old node folder.
A lowercase name can represent one node and an uppercase variable can represent
a group. Put a collection on one side of an edge to express an a-B fan-out or
an A-b fan-in:
A = ["extract_text", "extract_images"]
B = ["jsonify", "index"]
EDGES = [
("split", B), # split -> jsonify, split -> index
(A, "merge"), # extract_text -> merge, extract_images -> merge
]The explicit helper form is also supported:
from micro_workflow_manager import fan
EDGES = [
fan("split", ["jsonify", "index"]),
fan(["extract_text", "extract_images"], "merge"),
]A collection on both sides is rejected because that would describe a complete bipartite graph rather than one directed fan.
A finished job is not the same thing as a finished scheduling component. MWF
separates ordinary dependency direction from explicit autostart communication.
Let G = (V, E) be the project graph and let A ⊆ E contain exactly the edges
statically declared with autostart=True. Construct:
G_H = (V, E union reverse(A))
reverse(A) = {(v, u) : (u, v) in A}
Hoeflein(G) = SCC(G_H)
HDAG(G) = G / Hoeflein(G)
The quotient edges come from the original E, not from the synthetic reverse
arcs. Therefore, with A -> C and B autostarts C, B and C form one component
and the quotient is:
A -> {B, C}
There is no reverse dependency from {B,C} to A. Running B or C selects the
whole {B,C} component, but it is refused until A is complete.
Within one Hoeflein component, every original directed edge behaves as component-autostart. Child work is queued and the component scheduler keeps one live pump per active member node. It polls idle sibling queues while other pumps are still running, so a newly routed handler job does not wait for the router's entire queue to drain. Work is never executed recursively inside the parent handler. Queued components remain queued before execution; once active, member nodes quiesce and fail as one lifecycle unit. A node that has no jobs is vacuously successful when the component's actual jobs all finish.
A partial runfrom deliberately permits a later merge component to process the
selected incoming branch while other incoming branches remain unfinished. That
component may reactivate when another producer creates new jobs later. Starting
component readiness is stricter: all external predecessors of the start
component must already be complete.
See examples/autostart_cycle_lab and the fan, K4, and C5 cyclic tests for
runnable communicating-component examples.
Every generated job records:
parent.from_node
parent.from_job_id
producer_component
job_kind = dag | component
component jobs were generated inside their target Hoeflein component. dag
jobs crossed a quotient-DAG edge. Root/default jobs have no producer.
For a fresh mwf run COMPONENT_MEMBER, MWF first deletes every job produced by
that component, including internal component jobs and jobs it produced in later
components. It then requeues every remaining job in the selected start
component, including jobs created there by an external predecessor. For
mwf runfrom, that same full reset applies to the start component. Descendant
merge components rebuild selected-producer and root/default work while preserving
completed jobs produced by unselected incoming branches.
Example with ordinary edges A -> C and B -> C:
mwf runfrom A # creates and completes A-produced jobs in C
mwf runfrom B # preserves A-produced jobs; rebuilds only B-produced jobsNode-level output/ is preserved whenever a node still contains jobs from an
unselected producer, because that directory may contain shared debugging
provenance. Job-local outputs remain attributable and are removed only with the
job that owns them.
mwf run and mwf runfrom no longer invent a default starter job. Declare default jobs in the respective node file:
from micro_workflow_manager import NodeRouter
router = NodeRouter("split", max_threads=2)
router.create_job(number=2, params={"message": "hello"})
@router.task
def split(ctx, message):
print(message, ctx.job_id)number=2 creates jobs 1 and 2 with the same params. Multiple router.create_job(...) calls are allocated deterministic job ids in the order they appear. These declarations are idempotent when the CLI imports node files repeatedly.
Use a NodeInputFileSystem to make the destination visible at the top of the
node file and to route both files and job parameters:
from micro_workflow_manager import NodeInputFileSystem, OutputFileSystem
OUTPUT = OutputFileSystem("split pages")
TAGIFY_INPUT = NodeInputFileSystem("tagify", "tagify page input")
@router.task
def split(ctx):
page = OUTPUT.file(ctx, "page_001.txt")
page.write_text("page text")
incoming = TAGIFY_INPUT.file(ctx, page.name)
incoming.copy_from(page, overwrite=True)
TAGIFY_INPUT.add_job(ctx, page_file=incoming.relative_path)The downstream node reads page_file with its own InputFileSystem. Job
creation remains explicit, so preparing a file never silently invents work.
ctx.transaction() and idempotency keys continue to work because
NodeInputFileSystem.add_job() delegates to the same guarded NodeHandle.add()
operation.
For high fan-out, batch publication without coarsening the downstream jobs:
records = [(f"items/{i}.json", value) for i, value in enumerate(values, 1)]
TAGIFY_INPUT.write_jsons(ctx, records, overwrite=True)
TAGIFY_INPUT.add_jobs(
ctx,
[{"record_file": filename} for filename, _ in records],
autostart=False,
idempotency_keys=[f"record:{filename}" for filename, _ in records],
)This still creates one tagify job per record. With autostart=False, the
producer and consumer remain separate Hoeflein components; the optimization is
only in file and SQLite registration.
Deployment is explicit and uses a project-root .mwfignore, similar in spirit
to .gitignore and .dockerignore. Later rules override earlier rules and a
leading ! re-includes a path. Server passwords are never stored.
Configure a server and create the default ignore file:
mwf deploy setupThe default .mwfignore excludes Git/editor metadata, .mwf/, virtual
environments, Python caches, build output, and .env files. Review it before
every sensitive deployment. Password authentication uses PuTTY pscp and
plink; key authentication normally uses OpenSSH scp and ssh, while .ppk
keys use PuTTY. Setup stores connection metadata at .mwf/deploy/server.json.
Build a local deployment:
mwf deploy localThis command deletes the previous .mwf/deploy/local/ copy, filters the project
through .mwfignore, compresses every direct node/<name>/ subfolder into its
own ZIP, and creates one outer deployment.zip. If a node subfolder contains no
ignored path, MWF zips it directly without staging every small file first. The
command prints ongoing copy/ZIP counts and final sizes. Rebuilding overwrites the
old local archive so repeated tests do not accumulate large deployments.
Upload and extract it on the configured server:
mwf deploy remoteIf no local deployment exists, MWF asks whether to build one. If one does exist, it asks whether to deploy that archive or rebuild it first. It then asks for the server destination path, uploads one compressed file, and uses remote Python to extract the outer archive and each node archive. Files with matching paths are overwritten; unrelated files already on the server are left in place.
Noninteractive setup fields are also available for scripts:
mwf deploy setup --host 192.0.2.10 --user worker --port 22 --auth key --key C:\keys\server_key
mwf deploy remote --path /home/worker/simple_flow --yesThe default runner is threaded.
mwf graph src/graph.py --runner threaded
mwf runfrom start_nodethreaded is dependency-free and uses Python's local thread pool. It runs:
- multiple queued jobs inside the same node at the same time, capped by that node's
max_threads - multiple ready nodes at the same time, while still respecting DAG predecessor completion
- newly-ready downstream nodes while unrelated nodes are still running
Use api for blocking HTTP clients, SDK calls, database drivers, filesystem
waits, or other jobs whose wall time is mostly external latency:
mwf graph src/graph.py --runner api
mwf runfrom fetch_requestsrouter = NodeRouter("fetch_requests", runner="api", max_threads=64)For this runner, max_threads=64 intentionally means at most 64 in-flight API
jobs for that node. By default, independent API node limits add together.
For a shared provider or account, set a run-scoped aggregate admission budget
with mwf threads --api-total N. MWF divides that budget proportionally by the
active API nodes' effective max_threads requests, while retaining each request
as an upper bound. The familiar max_threads name is retained so router code,
mwf threads, monitor, and inspect use one concurrency vocabulary. Unlike
the adaptive threaded runner, api fills its available per-node slots
immediately. Executor threads are still created lazily, and io and network
are aliases.
The runner worker is the attempt controller. Retry, repeat, and fallback logic runs synchronously in that controller. Normal untimed programmatic direct calls execute the user handler in the caller thread. A timeout-supervised or CLI-restartable attempt creates only one extra daemon handler thread:
runner worker/controller -> one mwf-handler-* user thread
There is no intermediate mwf-attempt-* thread. If a timeout or manual restart
abandons the handler, generation fencing immediately prevents stale MWF-managed
writes and downstream job creation while the controller proceeds to the next
fallback, retry, or generation.
The router's max_threads value remains the readable source-code default. For
local testing, use mwf threads to apply a temporary project-local override
without editing the node behavior file or restarting an active workflow:
mwf threads # list declared, override, and effective values
mwf threads explode # inspect one node
mwf threads explode 24 # set an absolute runtime limit
mwf threads explode +8 # add eight slots
mwf threads explode -4 # remove four slots
mwf threads explode reset # return to the router declaration
mwf threads --api-total 512 # cap aggregate API admission proportionally
mwf threads --api-total reset # remove the aggregate API budgetThe override is stored in .mwf/threads.json, which is ignored by the generated
.gitignore. It is deliberately temporary: an override set before execution
applies to the next run only, and an override changed during execution belongs
to that active run. MWF removes the override when the run finishes, including
failed runs. Stale values bound to an older crashed run are ignored and removed
when a new run claims the project.
For an active threaded or API node, an increase starts more queued jobs within roughly 0.2 seconds. A decrease never cancels jobs already running; the runner stops launching replacements until active concurrency falls to the new limit.
API values are cooperative fiber counts. They may be set into the thousands
without one controller or supervisor OS thread per request. Values from multiple
API nodes add together unless the optional aggregate budget is set. The budget
is applied before execution claims, so excess jobs remain truthfully queued
instead of becoming running while waiting behind a late network semaphore.
Provider, socket, memory, and rate limits still apply. Threaded and process
runners retain their OS-worker safety ceiling and warnings.
If a process is killed while changing the override, the next command detects that the advisory-lock owner is no longer alive and immediately reclaims the lock. It does not wait for the old five-minute lease to expire.
mwf inspect NODE shows the declared, overridden, and effective values.
mwf monitor shows the effective per-node value in its threads column and
marks runtime overrides with *. Each API runner grows toward its proportional
share when an aggregate budget is active, or toward its own node limit when it
is not. The adaptive threaded runner grows toward its OS-worker limit. A process
runner reads the override when its process pool is created; an already-created
process pool is not resized live. The direct runner always remains at one job.
For CPU-heavy work, use the process-pool runner:
mwf graph src/graph.py --runner process
mwf runfrom start_nodeprocess mirrors the threaded runner's workflow behavior, but jobs run in child Python processes through ProcessPoolExecutor. It still runs multiple ready nodes at the same time, streams large job queues lazily, respects DAG readiness, and uses each node's max_threads value as the process-worker cap for that node. processes, process_pool, and processpool are accepted aliases.
Process mode is meant for normal CLI/router projects where child processes can rebuild the workflow from src/graph.py and src/node_behavior/*.py. Keep process-run node code in importable files, and return pickleable values such as strings, numbers, lists, dicts, or Path objects. On Windows, use the CLI or put programmatic runs behind if __name__ == "__main__":.
A node can override the global runner:
from micro_workflow_manager import NodeRouter
router = NodeRouter("ocr_pages", max_threads=4, runner="process")
router.create_job(number=8)
@router.task
def ocr_pages(ctx):
# CPU-heavy page work here. With runner="process", up to 4 jobs for
# this node run in separate Python processes.
text = f"processed page job {ctx.job_id}"
ctx.write(f"page_{ctx.job_id}.txt", text)
return textFor step-by-step debugging, use the direct runner:
mwf graph src/graph.py --runner direct
mwf runfrom start_nodeRun a read-only project check before a long workflow or after changing files:
mwf doctormwf doctor compares the graph, node folders, and router files; checks important
JSON state; reports stale active-run records and abandoned running jobs; and warns
about simple literal ctx.node("B") calls without a declared edge. It also warns
when MWF-owned metadata should be upgraded with mwf migrate. It does not repair
or modify the project. Errors produce a nonzero exit status.
Use inspect when you need an explanation rather than a raw directory listing:
mwf inspect A
mwf filter A
mwf inspect A failed
mwf inspect A job 3mwf filter A shows how many jobs entered, passed, and remained after
each main retry and fallback retry. It derives the funnel on demand from the
latest execution segment in each job's append-only events, so it adds no shared
provenance manifest or scheduler hot-path writes. It intentionally does not
append failed-job details. mwf filter A stage 2 lists jobs that failed stage 2
and then completed successfully at stage 3; selecting the final stage lists the
terminally failed jobs in the same compact job_id: error format.
Node inspection explains readiness, blockers, status counts, Hoeflein-component
membership, runner, total timeout, checkpoint timeout, and fallbacks.
Job inspection additionally shows the current/last handler, named checkpoint,
checkpoint deadline, progress percentage, progress detail, execution generation,
child jobs, and chronological lifecycle events. Checkpoint state and lifecycle events are stored in .mwf/state.sqlite3; they are scheduler diagnostics, not task output or a provenance manifest. mwf inspect renders records such as created, started, fallback_started, timeout, restart_requested, and done. output.json and job-local returned files remain the actual task result.
Low-churn MWF JSON metadata such as .mwf/project.json, .mwf/run.json,
.mwf/threads.json, and node schema.json carries an explicit
schema_version. High-churn job and scheduler state has its own SQLite schema
version. Neither scheme applies to user input.json, output.json, returned
files, node input/, or node output/.
Preview and apply an upgrade from an older project:
mwf migrate --dry-run
mwf migrateMigration upgrades low-churn JSON atomically and initializes/upgrades SQLite transactionally. A one-time importer reads legacy job metadata before deleting those framework-owned sidecars. mwf migrate --dry-run does not create the database or import/delete files. MWF refuses state that claims a newer incompatible schema.
Several destructive commands support a read-only preview:
mwf graph --update --dry-run
mwf clean A --dry-run
mwf reset A --dry-run
mwf wipe A --dry-run
mwf recover --dry-run
mwf restart <node-name> job 4 --dry-runExecution commands provide --plan instead of pretending to run:
mwf run A --plan
mwf runfrom A --plan
mwf runfrom A refuseafter C --plan
mwf resume A --plan
mwf resumefrom A --planA plan prints the selected nodes and jobs, reset-versus-resume semantics, detected static autostarts, external blockers, and current status counts. It does not claim the active-run slot or change state. Dynamic jobs created by task functions are reported as runtime-dependent rather than guessed.
A CLI-owned run records its process ID, hostname, command, selected nodes, MWF
version, and a lightweight heartbeat in .mwf/run.json. The same single
scheduler-supervisor thread that manages timeout deadlines updates this run
heartbeat. Run liveness and job progress remain separate signals: the run
heartbeat proves the scheduler process is alive, while a job checkpoint proves
that one handler reached a progress boundary. Normal scheduling does not scan
the project for liveness.
If the owning process has crashed, recover abandoned running jobs without
resetting completed work:
mwf recover --dry-run
mwf recoverRecovery refuses to compete with a demonstrably live owner. For each abandoned
job it advances the execution generation before requeueing it, so a late stale
process cannot commit afterward. Jobs already marked done, skipped, or
failed are not reset by recovery.
Continue a failed partial run while preserving successful jobs:
mwf resume B
mwf resumefrom Aresume continues one node. resumefrom continues that node and its descendants.
Both preserve done and skipped jobs and their outputs, leave queued jobs
available, and requeue only failed, cancelled, or abandoned-running jobs. By
contrast, run and runfrom retain their fresh-reset behavior.
Trace retention is independent of output retention. Fresh/destructive commands
clear the affected job transcript unless --keeptrace is supplied:
mwf run A --keeptrace
mwf runfrom A refuseafter C --keeptrace
mwf reset A --keeptrace
mwf clean A --keeptrace
mwf wipe A --keeptraceresume A preserves the current Hoeflein component's trace without requiring
the flag. Default resumefrom A also preserves that start component, but clears
trace journals for selected descendants before continuing. Add --keeptrace to
retain those descendant histories as well.
MWF has a total handler timeout and dynamic checkpoint deadlines. Declare the
hard upper bound with timeout= on the task or fallback, then choose the maximum
allowed silence for each section in task code:
from micro_workflow_manager import NodeRouter
router = NodeRouter("process_number")
@router.task(timeout=300)
def process_number(ctx):
ctx.checkpoint(
"preparing request",
timeout=20,
progress=0.1,
detail="building parameters",
)
prepare()
ctx.checkpoint(
"waiting for service",
timeout=90,
progress=0.25,
)
call_service()
ctx.checkpoint(
"saving result",
timeout=15,
progress=0.8,
)
save_result()
return "finished"Each timeout= passed to ctx.checkpoint() means the handler must either finish
or reach another checkpoint before that many seconds pass. Reaching a checkpoint
refreshes the scheduler-owned deadline. The task/fallback timeout= is still the
hard upper bound for the whole attempt. The older router/task
checkpoint_timeout= default remains accepted for compatibility, but dynamic
checkpoint deadlines in task code are preferred.
progress is a fraction from 0 to 1. detail and the checkpoint name are
optional human-readable values displayed by:
mwf inspect process_number failed
mwf inspect process_number job 3All configured total/checkpoint deadlines are managed by one workflow-owned scheduler supervisor using a deadline heap. There is no timer thread per job and no repeated scan of every job folder. Untimed handlers without checkpoints keep the original direct invocation path. An explicit progress checkpoint updates only that job row in SQLite on demand.
When a watchdog deadline expires, MWF sets the attempt's cancellation fence,
records one timeout event, wakes the normal fallback/retry path, and prevents the
abandoned handler from using MWF-managed writes or downstream-job creation.
Python still cannot force-kill an arbitrary thread blocked inside an external
library, so external request timeouts remain useful and direct side effects made
outside ctx helpers cannot be rolled back. The process runner can isolate such
code more strongly.
ctx.raise_if_cancelled() checks restart/timeout state without reporting
progress. ctx.sleep(seconds) checks cancellation in short intervals but does
not fabricate progress checkpoints.
For a single downstream creation that may be retried, provide an idempotency key:
@router.task
def A(ctx):
return ctx.node("B").add(value=4, idempotency_key=f"A:{ctx.job_id}:B")The same target node and key return the existing job instead of creating a duplicate. For several downstream jobs, stage them until a block succeeds:
@router.task
def A(ctx):
with ctx.transaction():
first = ctx.node("B").add(value=1)
second = ctx.node("B").add(value=2)
return [first.job_id, second.job_id]Only ctx.node(...).add(...) operations are staged. If the block raises, none are
created. Successful commits use deterministic per-parent-and-operation keys, so retries,
resume, and manual restart generations complete a partially committed transaction
without duplicate jobs. This is
opt-in; ordinary downstream creation retains its existing fast path.
The cleanup commands support --dry-run and preserve their existing semantics:
mwf clean A --dry-run # would remove jobs/output, keep input
mwf reset A --dry-run # would keep jobs/input and requeue all jobs
mwf wipe A --dry-run # would remove jobs/output/inputThe preview resolves * and validates node names but does not remove files or
change statuses.
Use the full dashboard in the same terminal as an execution command:
mwf run start_node --monitor
mwf runfrom start_node --monitor
mwf runfrom start_node --monitor --monitor-interval 0.5Inline monitoring prints timestamped snapshots without clearing earlier task or
monitor output, making it suitable as a diagnostic timeline. The final snapshot
is emitted after the run record becomes terminal and therefore says
active run: none; the previous sequence is shown separately as the last run.
For an independent observer, open a second terminal and use:
mwf monitor
mwf monitor --once # one snapshot
mwf monitor A B # monitor selected nodes only
mwf monitor --json --once # machine-readable snapshotmwf monitor reads SQLite job/node summaries plus the low-churn run record. It
shows running nodes, queued/running/done/failed counts, jobs left, progress,
running job IDs, effective concurrency, average completed duration, and rough
ETA without calling task code. For scheduler and startup debugging, use the
event-driven htop-style view:
mwf top
mwf top --once
mwf top explodeclaim explodecontext
mwf top --once --jsonmwf top wakes on durable lifecycle commits rather than waiting for the redraw
interval. It adds starts/finishes per second, queue/terminal p95 latency, recent
events, active-process RSS and thread count, SQLite/WAL size, and the mutation
writer's active batch and durability backlog. The interval is only a maximum
redraw/fallback cadence. Compact same-terminal lines remain available:
mwf runfrom start_node --stats
mwf run start_node --stats --stats-interval 10--monitor and --stats may be combined. ETA is intentionally approximate and
becomes more useful after at least one relevant job has finished. See
AGENT.md for using snapshots to separate resource pressure, timeout
policy, test-code stalls, and scheduler defects.
Keep the original mwf run, runfrom, resume, or resumefrom terminal
running and issue restart controls from a second terminal. Naming a node selects
its whole active Hoeflein component; an ordinary DAG node selects only itself.
# Restart every live-running and failed/cancelled job in the component.
mwf restart <node-name>
# Restart failed/cancelled jobs only.
mwf restart <node-name> failed
# Retain precise job selection when needed.
mwf restart <node-name> job 42
mwf restart <node-name> jobs 42 57 80-82Restart never launches a second scheduler. For each selected job it advances the
execution generation, clears job-local output.json and generated files, and
leaves the existing run in control. A stale generation cannot commit framework
status, files, output, or downstream jobs. Already queued, done, and skipped jobs
are not reset by the component-wide forms.
The active run record stores component membership, so the second-terminal command does not need to import project graph or handler code. Run records from older versions fall back to singleton-node scope.
After the owning sequence has ended, use:
mwf resume <node-name>
mwf resumefrom <start-node>Resume first registers output-backed terminal jobs that were still recorded as
running, waits for those SQLite mutations to become durable, and only then
requeues failed, cancelled, or genuinely abandoned-running work. Done and
skipped work remains untouched.
MWF 0.3.4 no longer creates per-job queue markers, status files, execution
files, runtime files, event logs, or a shared job_index.json. High-churn state
is normalized into .mwf/state.sqlite3:
jobsstores job identity, status, execution generation/lease, and checkpoint runtimejob_eventsstores chronological lifecycle, retry, fallback, timeout, and restart recordsidempotencystores downstream creation keysdefault_job_specsstores idempotent router starter declarationsnodesstores node-level stateadvisory_locksprovides infrequent CLI-wide cross-process critical sections
Per-job execution/restart fences are intentionally not database advisory rows.
They use .mwf/execution-fences/*.lock, so a managed payload write does not add
an advisory-lock acquire and release around its normal work. Checkpoints update
their job runtime with one conditional SQLite statement. Framework-created
worker, handler, component, supervisor, and inline-monitor threads close their
connection at lifecycle end; same-process writers queue before entering SQLite,
while WAL keeps monitor/inspect readers concurrent.
The scheduler allocates dynamic job IDs and updates status counts with short transactions. WAL mode allows concurrent monitoring and inspection. This avoids thousands of tiny filesystem operations and removes contention on a shared index file for high-fan-in nodes.
The user-visible source of payload truth stays on disk:
node/<name>/input/andnode/<name>/output/node/<name>/jobs/<id>/input.jsonnode/<name>/jobs/<id>/output.jsonnode/<name>/jobs/<id>/files/when the job returns or writes files
A job that never creates returned files does not need an empty files/ folder.
Use mwf inspect, mwf monitor, and mwf doctor rather than querying SQLite
directly in application code.
Use a project-local virtual environment so the package can be removed without changing the system Python installation.
From the framework source directory containing pyproject.toml, create and
activate a virtual environment, then install an editable development copy:
py -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install -e ".[test]"On Linux or WSL, activate with source .venv/bin/activate instead.
An editable installation points Python at the source directory, so code changes are visible immediately. Use this form while developing MWF itself.
A wheel is an installation-ready .whl package. Build one from the framework
source directory containing pyproject.toml:
python -m pip install --upgrade build
python -m build --wheelThe wheel is written to dist/. For version 0.4.3 the expected filename is:
micro_workflow_manager-0.4.3-py3-none-any.whl
py3-none-any means the package is pure Python, supports Python 3, and does not
contain operating-system-specific compiled code.
To build both a wheel and a source archive, run:
python -m buildThis creates the wheel and a .tar.gz source distribution under dist/.
Install the wheel by giving pip its actual file path. From the framework source directory after building:
python -m pip install --force-reinstall .\dist\micro_workflow_manager-0.4.3-py3-none-any.whlFrom Linux or WSL:
python -m pip install --force-reinstall ./dist/micro_workflow_manager-0.4.3-py3-none-any.whlIf the wheel is in Downloads or another directory, use its full path:
python -m pip install --force-reinstall "C:\path\to\micro_workflow_manager-0.4.3-py3-none-any.whl"Do not write .micro-workflow-manager==0.4.3; that is interpreted as a malformed
package requirement rather than a file path. On PowerShell, a file in the
current directory begins with .\, and the wheel filename uses underscores.
Verify the installed version, module location, and CLI:
python -c "import micro_workflow_manager; print(micro_workflow_manager.__version__); print(micro_workflow_manager.__file__)"
mwf --helpA project can bundle the wheel in a directory such as vendor/ and reference it
from requirements.txt:
./vendor/micro_workflow_manager-0.4.3-py3-none-any.whl
Then users can install the project and its framework together from the project root:
python -m pip install -r requirements.txtThe package installs no Windows service, daemon, scheduled task, registry entry,
or background process. Runtime state stays in the project under the consolidated .mwf/ directory and node/. Stop any active mwf run, mwf runfrom, mwf resume, mwf resumefrom,
or mwf monitor process before uninstalling, especially on Windows where an
active mwf.exe launcher can be locked.
python -m pip uninstall micro-workflow-managerDeleting the project-local .venv removes the entire isolated installation as
an alternative. Deleting the Python package does not delete workflow project
data; remove .mwf/ and node/ separately only
when you intentionally want to remove that data.
If an older interrupted pip operation reports an invalid distribution such as
~icro-workflow-manager, close all Python/MWF processes and remove only the
stale temporary entries from that virtual environment, then reinstall or
uninstall normally:
Get-ChildItem .\.venv\Lib\site-packages -Force |
Where-Object { $_.Name -like "~icro*" } |
Remove-Item -Recurse -Force
Remove-Item .\.venv\Scripts\mwf.exe -Force -ErrorAction SilentlyContinue
python -m pip install --force-reinstall .
python -m pip uninstall micro-workflow-managerRead HOW_TO_TEST.md before running the suite. It is the authoritative test execution order for this repository. In particular:
- ordinary tests are run together as a batch with
tests/test_autostart_cycles.pyexcluded; - every test in
tests/test_autostart_cycles.pylisted by the runbook is run in its own fresh pytest/Python process and must not be combined; and - the marked long stress test is run explicitly.
AGENT.md contains the broader contributor and diagnosis protocol,
but agents must use HOW_TO_TEST.md for the exact release-verification
commands and ordering.
A supervised task can report progress and set the deadline for its next section:
@router.task(timeout=300)
def work(ctx):
ctx.checkpoint("request started", timeout=60, progress=0.2)
result = call_service()
ctx.checkpoint("response received", timeout=20, progress=0.8, detail="validating")
return resultJobContext.checkpoint() accepts name, timeout, progress, and detail.
The timeout value means that the handler must either finish or reach another
checkpoint before that many seconds pass. Progress is a number from 0 through 1
and is shown by mwf inspect NODE job ID. The total task/fallback timeout=
keeps the handler on the centralized scheduler-supervised path; checkpoint
timeouts may then be chosen dynamically in task code.
A local or copied remote deployment may remain compressed as deployment.zip.
MWF can unpack the main archive and all independently compressed node/<name>.zip
folders during initialization:
mwf init deployment.zipWhen no archive argument is supplied, mwf init checks these common locations:
./deployment.zip./mwf-deployment.zip./.mwf/deploy/local/deployment.zip
Extraction rejects paths that escape the project directory. Initialization prints each major step and each node archive as it is unpacked.
Save a node's payload folder and a cold SQLite state snapshot beside node/:
mwf copy preprocessThis replaces clipboard/preprocess while leaving other saved nodes intact.
Restore it later with:
mwf paste preprocessPaste replaces node/preprocess and restores that node's jobs, statuses, events, idempotency keys, and default-job declarations from clipboard/preprocess/.mwf-node-state.sqlite3. Pre-0.3.4 clipboard copies without a snapshot remain supported as payload-only copies. The default
.mwfignore excludes clipboard/, .mwf/, .venv/, version-control metadata,
editor metadata, caches, and build output.
The SQLite snapshot includes the complete trace journal for every copied job.
After mwf paste preprocess, mwf trace preprocess job ID therefore renders the
same preserved transcript that existed when the copy was made.
mwf inspect preprocess debugThis prints the node's output/debug.txt path and contents, or explains that the
file does not exist yet.
After changing max_threads= or a node-level runner= in src/node_behavior/*.py, refresh the mounted schemas without synchronizing the graph:
mwf threads --updateThis reloads the synchronized node behavior files and updates their declared concurrency and runner values. It does not change graph edges, create/delete node folders, or clear runtime overrides. Use mwf threads NODE reset separately when an override should be removed.
mwf paste NODE now synchronizes the restored payload folders with SQLite before returning. Clipboard snapshots made before 0.3.4 have their numeric jobs/<id>/input.json payloads rebuilt as queued database jobs. A snapshot captured while a job was running is treated as a cold restore: stale running leases are cleared and those jobs are immediately queued, so the node can be run or resumed without another migration command.
mwf clean NODE, mwf reset NODE, and mwf wipe NODE expand NODE to its
whole Hoeflein component. Use --dry-run to see the expanded component before
anything changes. Selecting several members of one component does not duplicate
work. * still selects every graph node.
Inside a task or fallback, append structured trace objects with:
ctx.trace("model request", input=payload, output=reply, model=model)
ctx.trace("validator", status="warning", content={"mismatches": mismatches})Render the complete chronological transcript with:
mwf trace <node> job <id>
The transcript includes origin, main/fallback starts, custom traces, MWF-aware
output writes, forwarded inputs, downstream job creation, and the terminal state.
MWF supplies the canonical event timestamp and execution provenance automatically;
reserved keys supplied by user code, such as time, are retained under a
trace_ prefix instead of replacing framework metadata.
When --keeptrace preserves a transcript across deletion and recreation of the
same node/job ID, the new creation is compared with the most recent preserved
origin. A different parent, producer component, or job kind adds an
ORIGIN CHANGED block containing the previous and current provenance before the
new task/fallback events.