Skip to content

Major refactor: scheduling, shared event loop, CLI extraction, observability#40

Merged
lukastk merged 15 commits into
mainfrom
feat/major-refactor
May 2, 2026
Merged

Major refactor: scheduling, shared event loop, CLI extraction, observability#40
lukastk merged 15 commits into
mainfrom
feat/major-refactor

Conversation

@lukastk

@lukastk lukastk commented May 1, 2026

Copy link
Copy Markdown
Owner

Summary

Implements the full refactor plan in _dev/refactor_plan.md, Phases 0-5. Touches the execution model, scheduling, pool internals, the CLI, and documentation.

Phase 0 — Bug fixes + observability hooks

  • ExecutionManager.run() now cleans up _worker_jobs in finally so timeouts/cancellation don't leak stale entries.
  • _async_worker_func._handle_run always emits a terminal event — uncaught user exceptions no longer hang the manager.
  • netrun validate no longer silently swallows resolution failures.
  • faulthandler.register(SIGUSR1, all_threads=True) at Net start; gc.collect() + RSS delta tracking between retries.

Phase 1 — Shared event loop

  • Single asyncio loop per process (daemon thread) replaces per-worker loops. Workers submit coroutines via run_coroutine_threadsafe().
  • Auto-detects sync vs async from the function signature; sync nodes still run directly on the worker.
  • Subprocesses, nested awaits, cross-node asyncio.Lock all just work — no more nest_asyncio workarounds.

Phase 2 — Scheduling constraints + retry state

  • ctx.state: per-epoch mutable dict that persists across retries. Cache expensive precomputation so retries don't redo it.
  • NodeExecutionConfig.depends_on: list[str]: directional ordering without data edges. Cycles and unknown nodes raise ValueError at Net construction.
  • NodeExecutionConfig.resources: dict[str, int] + NetConfig.resources: named semaphores. Replaces the heavy-pool workaround for mutual exclusion / bounded concurrency / GPU scheduling.

Phase 3 — Pool/RPC/EM cleanup

  • Extracted BasePool for shared pool lifecycle.
  • Removed dead protocol weight: send_channel parameter, UP_RUN_STARTED (merged into UP_RUN_RESPONSE), UP_PRINT_BUFFER, NetProtocolKeys enum.
  • Worker crash isolation — a crashed worker only fails its own jobs.
  • Validate depends_on for cycles + invalid references.

Phase 4 — Config cleanup

  • Removed unused config fields, consolidated epoch state.

Phase 5 — CLI extraction + new commands

  • CLI extracted to a separate netrun-cli package at the repo root. Same nblite workflow as netrun. The netrun script is now installed via netrun-cli's [project.scripts]. The netrun package no longer ships a CLI or cli extras group.
  • New CLI features:
    • netrun node <name> --edges — show incoming/outgoing edges for a node.
    • netrun structure --format=mermaid — emit a Mermaid topology diagram (renders in GitHub, VSCode, etc.).
    • netrun dry-run — show topological execution order, source/sink nodes, pool assignments, and scheduling constraints (no node code runs).

Documentation audit

  • Rewrote CLAUDE.md (repo root) for: separated netrun-cli package, scheduling constraints, ctx.state, shared event loop, worker crash isolation, removed protocol elements.
  • Updated README.md (repo root) and netrun/README.md for the same.
  • Refreshed inline docstrings on ExecutionManager, Net, and NodeExecutionContext.
  • Deleted stale netrun/PROJECT_SPEC.md references.
  • Added 3 focused sample projects under sample_projects/:
    • 15_ctx_state — retry-persistent caching with ctx.state.
    • 16_depends_on — three nodes with no data edges, ordering enforced by depends_on.
    • 17_resources — three jobs serialized through a 1-slot resource on a 3-worker thread pool (proves serialization via wall-clock timing).

Stats

  • 92 files changed, +15,846 / -3,128.
  • 13 commits, each self-contained per the refactor plan's PR sequence.

Test plan

  • cd netrun && uv run pytest src/tests/ -q — all 1,305 tests pass
  • cd netrun-cli && uv run pytest src/tests/ -q — all 61 tests pass (50 inherited + 11 new for --edges, --format=mermaid, dry-run)
  • Sample 14 (14_web_scraping_pipeline) — comprehensive showcase using all new features
  • Samples 15/16/17 — focused demos, each verified to run with expected output (e.g. sample 17 reports ~0.6s wall-clock for serialised jobs, not ~0.2s)
  • Smoke test new CLI features against sample_projects/00_basic_net_project

Migration notes

For downstream consumers:

  • Required: replace netrun[cli] extras with a separate netrun-cli dependency.
  • Optional but recommended: replace 1-worker "heavy" pool patterns with resources={"name": 1}; replace ad-hoc retry caches (module-level vars, threading.local) with ctx.state; remove nest_asyncio.apply() workarounds.
  • No migration needed: Net, NetConfig, NodeExecutionContext, factory protocol, on_epoch_* callbacks, run_until_blocked, output queues, structured logging — all unchanged.

🤖 Generated with Claude Code

lukastk and others added 15 commits April 25, 2026 15:31
Phase 0 of the major refactor. Fixes three confirmed bugs and adds two
observability improvements, with regression tests for each.

Bug fixes:
- ExecutionManager.run() now cleans up _worker_jobs and _msgs via
  try/finally on cancellation/timeout (previously leaked stale entries,
  corrupting LEAST_BUSY allocation)
- Sync and async worker functions now catch exceptions and send error
  UP_RUN_RESPONSE instead of crashing the worker loop or hanging the
  client forever
- _msg_recv_task_func silently discards orphaned responses for cancelled
  jobs instead of raising KeyError
- netrun validate now surfaces resolution errors as warnings instead of
  silently swallowing them

Observability:
- faulthandler + SIGUSR1 registered in Net.init() for stack dumps on hang
- gc.collect() + peak RSS logging (DEBUG level) between retry attempts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 1 of the major refactor. Thread pool workers no longer create
per-worker event loops. Instead, a single shared event loop runs on a
dedicated daemon thread per process. Async node functions are submitted
via run_coroutine_threadsafe(), fixing three classes of production bugs:

- asyncio.create_subprocess_exec no longer hangs (shared loop has child watcher)
- Nested async works naturally (no nested run_until_complete)
- asyncio.Lock/Event work across concurrent workers (same event loop)

Key changes:
- NetFuncPreprocessor gets dual wrapper: __call__ (async, for SingleWorkerPool)
  and call_for_sync_worker (sync, for ThreadPool/MultiprocessPool)
- Sync user functions run directly in worker threads (no event loop overhead)
- Async user functions dispatch to shared loop, worker thread blocks on result
- ExecutionManager creates/owns the shared loop lifecycle
- Each multiprocess subprocess creates its own process-local shared loop
- _func_runner falls back to shared loop for async when no preprocessor is set

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 2 of the major refactor. Three additive API features that address
production pain without changing existing behavior.

ctx.state:
- Mutable dict on NodeExecutionContext preserved across retries
- Same dict instance reused across retry attempts for the same epoch
- Cleared on final success or permanent failure
- Lets retry-heavy nodes cache expensive state (e.g., 10GB DataFrames)

depends_on:
- New field on NodeExecutionConfig: depends_on: list[str]
- Node epoch can't start until all named dependencies have completed
  at least one epoch successfully
- Enforced in _filter_startable_epochs (scheduler-level gating)
- Replaces manual control-edge wiring for ordering constraints

resources:
- Named semaphores for bounded concurrency and mutual exclusion
- Net-level: NetConfig.resources defines capacity (e.g., {"heavy_mem": 1})
- Node-level: NodeExecutionConfig.resources declares requirements
- Acquired when epoch starts, released in finally block
- Replaces the heavy-pool workaround (single-worker pool for mutex)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 3 of the major refactor. Introduces BasePool abstract base class
that provides concrete recv(), try_recv(), start(), close(), and health
monitoring. ThreadPool, MultiprocessPool, and SingleWorkerPool now
extend BasePool, implementing only pool-specific logic.

BasePool provides:
- Unified recv/try_recv with error checking and timeout handling
- Lazy recv task startup with _create_recv_loops() override
- Health monitoring via _check_worker_health() override
- Worker silence detection (configurable threshold, logs warnings)
- Common lifecycle management (start/close with proper ordering)
- Context manager support

Pool migrations:
- SingleWorkerPool: 451 -> 310 lines (-31%)
- ThreadPool: 458 -> 323 lines (-29%)
- MultiprocessPool: 1102 -> 905 lines (-18%)
  Overrides close() for SHUTDOWN_COMPLETE protocol
- RemotePoolClient: unchanged (different architecture)

ExecutionManager cleanup:
- Removed _thread_worker_func and _multiprocess_worker_func wrappers
  (inline functools.partial at call sites)
- Replaced O(n) round-robin list with O(1) counter

Net reduction: -518 lines across pool+EM (853 added, 1371 removed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 4 of the major refactor. Cleans up config/runtime drift and
consolidates scattered epoch state into a single data structure.

Config fields removed (unused or partially wired):
- NodeExecutionConfig: defer_net_actions, capture_prints,
  print_flush_interval, print_buffer_max_size
- NetConfig: dead_letter_callback, dead_letter_path
- PoolConfig: capture_prints, print_flush_interval
These fields were defined and resolved but never read at runtime,
creating misleading configuration knobs.

Epoch state consolidated:
- Moved structured_logs from _epoch_log_buffers dict onto _EpochState
- Moved net_actions from _epoch_net_actions dict onto _EpochState
- Moved retry_state (ctx.state) from _epoch_states dict onto _EpochState
- Removed three separate dicts, all state now lives on the epoch record
- Easier to reason about epoch lifecycle, less error-prone cleanup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds 6 tests covering gaps identified in the feature test audit:

- test_ctx_state_cleared_on_success: verify retry_state cleaned up after success
- test_depends_on_multiple_dependencies: node C depends on both A and B
- test_depends_on_none_is_noop: no depends_on works as before
- test_resources_multi_slot: 2-slot resource allows bounded concurrency
- test_resources_undefined_raises: referencing undefined resource errors
- test_silence_detection_warns: worker silence triggers warning log

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Adds config-time validation to catch depends_on misconfigurations
that would otherwise cause silent deadlocks:

- Circular dependency detection via topological sort in Net.__init__
  (A depends_on B, B depends_on A → ValueError with clear message)
- Invalid node name detection (depends_on non-existent node → ValueError)
- Updated field docstrings with edge case documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously, when a worker crashed, BasePool.recv() raised WorkerCrashed
which killed _msg_recv_task_func — the central message receiver for the
entire pool. This made ALL workers in the pool unreachable, not just the
crashed one.

Now:
- BasePool.recv_raw() returns error messages without raising, for use
  by ExecutionManager's message receiver
- _msg_recv_task_func uses recv_raw() and routes pool-level errors to
  the specific jobs running on the affected worker
- _msg_worker_map tracks msg_id -> worker_id for error routing
- _recv_msg detects routed error messages and raises to the caller
- Other workers remain fully functional after a single worker crash

BasePool.recv() is unchanged — external callers still get exceptions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New sample project (14_web_scraping_pipeline) demonstrating every major
netrun feature in a single, realistic pipeline:

  [load_config] → [validate] → [broadcast] → 3 async scrapers → [join] → [analyze] → [export]

Features demonstrated:
- Async node functions on thread pool (shared event loop)
- ctx.state: caches computation across retries
- depends_on: setup_output waits for load_config without data edge
- resources: http_conn=2 limits concurrent scrapes to 2 of 3
- Broadcast factory: fans config to parallel scrapers
- Join factory: collects results from all scrapers
- Retries: scrape_stocks simulates intermittent 503 failures
- Structured logging (ctx.log) with key=value fields
- Node variables (ctx.vars) for runtime-configurable URLs
- Output queues for result collection
- Signals (epoch_finished/epoch_failed) on all nodes
- Thread pool with 3 workers + main pool

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…to sample

Replaces the fragile pattern of mutating func_preprocessor._shared_loop
from worker threads with a clean thread-local (_worker_state.shared_loop).

Why: The preprocessor must stay picklable for multiprocess pools. Previously,
thread pool workers set _shared_loop on the shared preprocessor instance,
which made it unpicklable if a multiprocess pool tried to serialize it later.
The __getstate__ band-aid was masking this design issue.

Now: _worker_state (threading.local) holds the shared loop. The preprocessor's
call_for_sync_worker reads it from thread-local when dispatching async functions.
No execution state stored on the preprocessor — it stays fully picklable.

Sample project updated:
- Added multiprocess pool ("compute") for the analyze node
- Added retain_epoch_logs for full log visibility
- Added net.logs.print_all() showing all node output including multiprocess
- All 3 pool types now demonstrated: main, thread, multiprocess

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Remove dead code and simplify the ExecutionManager protocol:

- Remove NetProtocolKeys enum (defined channel-based protocol that was
  never implemented — deferred actions replaced it entirely)
- Remove send_channel feature (Net always passed False; bidirectional
  channel during execution was never used)
- Merge UP_RUN_STARTED into UP_RUN_RESPONSE (eliminates one protocol
  message round-trip per execution; started timestamp now included in
  the single response)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…aid/dry-run

Prompt to recreate this work:

Extract the CLI from `netrun` into a new sibling Python package called
`netrun-cli` (following the `netrun-utils` pattern), and add three new CLI
commands.

Background: the CLI has a clean boundary — its only imports from netrun are
`netrun.net.config` and `netrun.tools`, and nothing in `netrun` imports from
`netrun.cli`. So extraction is safe.

Steps:

1. Create `netrun-cli/` at the repo root with:
   - `pyproject.toml` declaring `netrun-cli` v0.5.0 depending on
     `netrun>=0.5.0`, `typer>=0.15.0`, `tomli-w>=1.0`. Register
     `[project.scripts] netrun = "netrun_cli._app:app_main"`. Use the same
     ruff/pytest config and uv source pattern as `netrun-utils`.
   - `nblite.toml` with the standard `nbs -> pts -> lib` pipeline, exporting
     to `src/netrun_cli` and `src/tests`.
   - Empty `src/netrun_cli/__init__.py` (hatchling needs it to find the
     package; nblite does not auto-generate it).

2. Move the seven CLI source files from `netrun/pts/netrun/10_cli/*.pct.py`
   to `netrun-cli/pts/netrun_cli/`. In each file:
   - Change `#|default_exp cli._foo` to `#|default_exp _foo`.
   - Change `from netrun.cli._foo import ...` to `from netrun_cli._foo import ...`.
   - Leave `netrun.net.config.*`, `netrun.tools.*` imports untouched — those
     are external dependencies on the `netrun` package.
   - Leave hardcoded strings (`name="netrun"` in the Typer app, the
     `.netrun.json/.netrun.toml` extensions, GitHub repo refs in
     `download_agents`, `netrun.node_factories.broadcast` warning) untouched.

3. Move the two CLI test files from `netrun/pts/tests/10_cli/` to
   `netrun-cli/pts/tests/`. In each:
   - Update `#|default_exp` (drop the `cli.` prefix).
   - Update `from netrun.cli._app import app` -> `from netrun_cli._app import app`.
   - In `test_cli.pct.py`, the SAMPLE_DIR path traversal goes from `.parent`
     5 times to 4 times (one fewer directory level).
   - Replace all `netrun.cli._download_agents` mock-patch strings with
     `netrun_cli._download_agents` (~5 occurrences).

4. Run `nbl export --reverse && nbl export` in `netrun-cli/` to populate
   `nbs/` and `src/`.

5. Run `uv sync` in `netrun-cli/`. Then `uv pip install -e .` to install the
   `netrun` script entry point.

6. Delete `netrun/pts/netrun/10_cli/`, `netrun/pts/tests/10_cli/`,
   `netrun/src/netrun/cli/`, `netrun/src/tests/cli/`, and any matching
   directories under `netrun/nbs/`.

7. Edit `netrun/pyproject.toml`:
   - Remove `[project.scripts] netrun = "netrun.cli._app:app_main"`.
   - Remove `cli = ["typer>=0.15.0", "tomli-w>=1.0"]` from
     `[project.optional-dependencies]`.
   - Replace `"netrun[cli]"` in the dev dependency group with `"netrun-cli"`.
   - Add `netrun-cli = { path = "../netrun-cli", editable = true }` under
     `[tool.uv.sources]`.
   Then run `uv sync` in `netrun/`.

8. Add three new CLI features in `netrun-cli/pts/netrun_cli/02_commands.pct.py`:

   - `node <name> --edges`: when the flag is set, include an `edges` key in
     the JSON output with `incoming` (list of `{source: "node.port", port: ...}`)
     and `outgoing` (list of `{port: ..., target: "node.port"}`). Iterate
     `net_config.graph.edges` and filter by `target_node == name` /
     `source_node == name`.

   - `structure --format=mermaid|json` (default `json` for back-compat):
     when `format=mermaid`, emit `graph LR`, then one node line per node
     `<id>["<name>"]`, then one edge line per edge
     `<src> -->|"<src_port> → <tgt_port>"| <tgt>`. Sanitise IDs by replacing
     spaces and hyphens with underscores. Reject any other format with
     `typer.Exit(1)`.

   - `dry-run`: new command. Topologically sort the nodes (Kahn's algorithm)
     using data edges (skip `dependency=True`) plus `depends_on` entries as
     ordering edges. Emit JSON: `source_nodes` (no incoming), `sink_nodes`
     (no outgoing), `execution_order` (each entry has `node`, optionally
     `pools`, `depends_on`, `resources` from execution_config), and
     `total_nodes` / `total_edges`.

   Register `dry-run` in `00_app.pct.py` alongside the other top-level
   commands.

9. Add tests in `netrun-cli/pts/tests/test_cli.pct.py` covering all three:
   - `test_node_edges_incoming` / `test_node_edges_outgoing` /
     `test_node_edges_none` / `test_node_no_edges_by_default`
   - `test_structure_mermaid` (verify `graph LR`, node decls, edge labels) /
     `test_structure_json_default` / `test_structure_bad_format`
   - `test_dry_run_basic` / `test_dry_run_execution_order` /
     `test_dry_run_sink_nodes` / `test_dry_run_depends_on_and_resources`
     (the last builds a tmp config with depends_on + resources to verify
     ordering and metadata appear in the output)
   - Add `assert "dry-run" in result.stdout` to `test_help`.

10. Re-export and run: `cd netrun-cli && nbl export --reverse && nbl export
    && uv run pytest src/tests/ -v`. All 61 tests should pass.

11. Verify `cd netrun && uv run pytest src/tests/ -q`. All 1305 tests should
    pass (down from 1355 — the 50 CLI tests moved out).

12. Smoke-test the new features against
    `sample_projects/00_basic_net_project/main.netrun.json`:
    - `.venv/bin/netrun node add --edges -c <path>` shows edges
    - `.venv/bin/netrun structure --format mermaid -c <path>` emits Mermaid
    - `.venv/bin/netrun dry-run -c <path>` shows topo order

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ples

Prompt to recreate this work:

After completing Phases 0-5 of the major refactor (bug fixes, shared event
loop, ctx.state/depends_on/resources, BasePool extraction, CLI extraction
with --edges/--format=mermaid/dry-run), audit and update all repo
documentation against the current code state. Also add focused sample
projects for the three new scheduling/state features.

Steps:

1. Delete `netrun/PROJECT_SPEC.md` (file already gone, but scrub all
   references): remove the line in `netrun/README.md` documentation list,
   the line in `README.md` documentation list, the `**See netrun/PROJECT_SPEC.md
   for the full specification.**` line in `CLAUDE.md`, the
   `│   ├── PROJECT_SPEC.md     # Full specification` line in CLAUDE.md's
   repo structure, and the `# Additional execution options (from PROJECT_SPEC.md)`
   comment in `netrun/pts/netrun/06_net/00_config/01_nodes.pct.py`.

2. Update `CLAUDE.md`:
   - Reword the project structure intro to "split into the following
     components" (was "two main components") and add a `### netrun-cli (CLI)`
     subsection describing it as a separate package.
   - Remove "CLI for validation, inspection, and config conversion" bullet
     from the netrun feature list.
   - Update the Repository Structure ASCII tree: remove `PROJECT_SPEC.md`
     line, insert a `netrun-cli/` block under `netrun/` showing the same
     pts/src structure plus pyproject.toml note.
   - Remove the CLI entry from "Current Implementation Status" (line "8.
     **CLI** (`netrun.cli`) - ..."). Renumber remaining items. Add a closing
     line: "The CLI lives in the separate **`netrun-cli`** package — see
     "netrun-cli specifics" below."
   - In the ExecutionManager usage example, remove the `send_channel=False,`
     line and the `print(result.print_buffer)` line. After the example, add
     a paragraph about the shared event loop dispatch model and a paragraph
     about worker crash isolation. In the Protocol Keys list, remove
     `UP_RUN_STARTED` and `UP_PRINT_BUFFER`; add `UP_SEND_FUNCTION_RESPONSE`.
   - Augment the "Key Classes" list under Net Module: bold the new fields
     (`resources` on NetConfig; `depends_on`, `resources` on
     NodeExecutionConfig; `ctx.state` on NodeExecutionContext).
   - Append `, scheduling constraints (depends_on, resources),
     retry-persistent state (ctx.state)` to the Net "Features:" line.
   - Add a "#### Scheduling: depends_on and resources" subsection (with
     code example showing net-level `resources={"gpu":1, "http_conn":4}`
     and node-level `depends_on=["preprocess"], resources={"gpu":1}`, plus
     mutual-exclusion / bounded-concurrency patterns).
   - Add a "#### Retry-persistent state: ctx.state" subsection with the
     `pd.read_parquet` cache example and lifecycle/locality notes.
   - Delete the entire "### CLI (`netrun.cli`)" section.
   - Add a new "## netrun-cli specifics" top-level section after "### Core"
     covering: editing workflow, install/run instructions, a command table
     listing every command including new ones, and a "Adding a new command"
     four-step recipe.

3. Update root `README.md`:
   - In the netrun component description, replace "a CLI" mention with
     scheduling + ctx.state language.
   - Add a `### [netrun-cli](netrun-cli/) — CLI` component section after
     netrun, mentioning it depends on netrun for config models.
   - Add `netrun-cli/` to the Repository Structure ASCII tree.
   - Add a "### netrun-cli" Quick Start block with `uv sync`,
     `.venv/bin/netrun --help`, and example commands against
     `sample_projects/00_basic_net_project`.
   - Remove the PROJECT_SPEC.md line from the Documentation list.

4. Update `netrun/README.md`:
   - Expand the Key Features list: replace the old factories bullet with
     "function/broadcast/join factories"; add bullets for "Scheduling
     constraints (depends_on, resources)", "Retry-persistent state
     (ctx.state)", "Shared event loop", "Worker crash isolation". Drop the
     CLI bullet.
   - Add a closing line: "The CLI is shipped as a separate package: see
     [netrun-cli](../netrun-cli/)."
   - Remove the PROJECT_SPEC.md line from the Documentation list.

5. Update inline docstrings (.pct.py files; auto-export to .py via nblite):

   - `netrun/pts/netrun/05_execution_manager.pct.py`:
     - Add a class-level docstring on `ExecutionManager` covering the
       single-terminal-event protocol, worker crash isolation, and the
       shared event loop model (one daemon thread per process scope).
     - Update `__init__`'s docstring to list pool types as types
       (`ThreadPool`, `MultiprocessPool`, `SingleWorkerPool`,
       `RemotePoolClient`), not strings.
     - Rewrite `run()`'s docstring to emphasise: targets a specific worker,
       single `UP_RUN_RESPONSE` carries result+timestamps, returns
       `JobResult`, raises whatever the user fn raises. Drop any mention of
       `send_channel` or `UP_RUN_STARTED`.
     - Add a docstring to `run_allocate()` documenting the candidate-list
       expansion (pool ID -> all workers; tuple -> specific worker), the
       three allocation methods (ROUND_ROBIN/RANDOM/LEAST_BUSY), and the
       empty-list ValueError.

   - `netrun/pts/netrun/06_net/01_net/00_context.pct.py`: replace the
     `NodeExecutionContext` class docstring with one that explicitly
     documents `ctx.state` — what it is (per-epoch dict), retry semantics
     (same instance reused, cleared on success/permanent fail), locality
     (per worker for multiprocess/remote, picklability requirement), and
     a `pd.read_parquet`-style example. Note that `state` is NOT deferred
     unlike packet operations.

   - `netrun/pts/netrun/06_net/01_net/02_net.pct.py`: expand the `Net`
     class docstring to enumerate the major responsibilities (pool
     lifecycle, scheduling via depends_on+resources, retries with
     ctx.state, timeouts, cache/file-storage replay, output queues,
     signals/controls, structured logging, lifecycle callbacks). Note the
     shared event loop model and worker crash isolation. Mention the
     `async with Net(config) as net:` (and sync) context-manager usage.

   - `netrun/pts/netrun/06_net/00_config/01_nodes.pct.py`: remove the
     "# Additional execution options (from PROJECT_SPEC.md)" comment line
     before the `defer_init` field.

   Then run `cd netrun && nbl export --reverse && nbl export` to propagate.

6. Add three focused sample projects under `sample_projects/`:

   - `15_ctx_state/`: Single node `expensive_pipeline` that simulates a
     0.5s heavy data load on first attempt, caches it in `ctx.state["heavy"]`,
     then runs flaky logic that fails on attempt 1 and succeeds on attempt
     2 (reading `ctx.state["attempts"]` and incrementing). Config has
     `retries: 2`. The print logs should show "Loading heavy data" once and
     "Reusing heavy data from previous attempt — no reload" on retry.
     Output queue `results` collects `pipeline.out`.

   - `16_depends_on/`: Three nodes `step_a`, `step_b`, `step_c` with
     **no data edges** between them. `step_b.execution_config.depends_on
     = ["step_a"]` and `step_c.execution_config.depends_on = ["step_b"]`.
     Each node takes a `trigger` parameter. main.py injects `["go"]` to all
     three triggers up front; output queue `all_done` collects all three
     `out` ports. Verify the completion order matches `[A, B, C]`.

   - `17_resources/`: Three sleeping (0.2s) `gpu_job_1/2/3` nodes on a
     `threads` thread pool with `num_workers: 3`. Net-level
     `resources: {"gpu": 1}`; each node declares
     `execution_config.resources: {"gpu": 1}`. Inject all triggers up front.
     The total wall-clock time must be ~0.6s (serial), not ~0.2s (parallel).

   Each sample needs a `pyproject.toml` (standard form: `dependencies =
   ["netrun"]`, `[tool.uv.sources] netrun = { path = "../../netrun",
   editable = true }`), a `nodes.py`, a `main.netrun.json`, and a `main.py`.
   Note: in `main.netrun.json`, `output_queues` lives at the **top level**,
   not nested under `graph`.

7. Run sample 14, 15, 16, 17 with `uv run python main.py` to confirm they
   work. Confirm:
   - 15 prints `Result: 499542` (= sum(0..999) + 42 = 499500 + 42)
   - 16 prints `Completion order: ['A complete', 'B complete', 'C complete']`
   - 17 reports total wall-clock ~0.6s

8. Run the full test suites: `cd netrun && uv run pytest src/tests/ -q`
   (1305 pass) and `cd netrun-cli && uv run pytest src/tests/ -q` (61 pass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prompt to recreate this work:

The netrun-ui CI tests `test_config_schema.py::TestRegistrationCompleteness`
detect drift between the Pydantic config models in `netrun` and the field
registrations in `netrun-ui/src/lib/configFieldRegistrations.ts`. After the
major netrun refactor (Phases 0-5), three registrations are missing and six
are stale.

Steps:

1. In `netrun-ui/src/lib/configFieldRegistrations.ts`:
   - Remove stale registrations:
     * `NetConfig.dead_letter_path`
     * `NetConfig.dead_letter_callback`
     * `NodeExecutionConfig.defer_net_actions`
     * `NodeExecutionConfig.capture_prints`
     * `NodeExecutionConfig.print_flush_interval`
     * `NodeExecutionConfig.print_buffer_max_size`
   - Add missing registrations (all `'auto'`):
     * `NetConfig.resources`
     * `NodeExecutionConfig.depends_on`
     * `NodeExecutionConfig.resources`

2. In `netrun-ui/tests/test_config_schema.py::test_complex_field`,
   `dead_letter_callback` no longer exists. Replace it with `default_signals`
   (a `dict[str, ...]` field still on NetConfig that classifies as
   `FieldCategory.COMPLEX`).

3. Verify: `cd netrun-ui && uv run pytest tests/test_config_schema.py -q` —
   all 47 tests should pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empirically verify every Phase 0–5 feature in PR #40 against the live
runtime (parallel verification subagents driving repro scripts), then
fix the issues that surfaced:

1. ctx.state did not persist across multiprocess/remote retries — every
   retry sent the parent's still-empty dict because worker mutations
   never round-tripped back. Add a `state` field to NodeExecutionResult,
   populate it in `_get_execution_result()`, and copy it back into
   `_epochs[epoch_id].retry_state` after each attempt so the next retry
   sees prior-attempt mutations.

2. Non-picklable exceptions raised in worker code (or carried in
   NodeExecutionResult.exception) silently hung the parent on
   multiprocess pools — `channel.send(e)` blew up on PicklingError, the
   bare-except swallowed it, and no terminal UP_RUN_RESPONSE arrived.
   Add `to_picklable_exception()` helper that returns the original `e`
   if picklable or a `RuntimeError` shim that preserves the type name
   and repr; apply it in both EM worker error paths and the
   preprocessor wrapper that catches user exceptions. Replace the
   silent `except: pass` with a logged error so future failures are
   diagnosable.

3. `netrun validate` reported broken factory imports as warnings with
   exit 0, hiding real config breakage in CI. Promote resolution
   failures to `errors` with non-zero exit. Also temporarily add the
   config's `project_root_path` to `sys.path` during resolution so
   factories resolve the same way they will at runtime, and clean
   matching modules out of `sys.modules` afterwards so back-to-back
   validates of different sample projects don't pick up cached symbols.

4. Disabled-dep silent hang — when a node's `depends_on` referenced a
   node with `enabled=False`, the dependent waited forever with no
   diagnostic. Emit a logging.WARNING at Net construction listing
   dependents of disabled nodes (so users running with controls can
   still enable at runtime).

5. Undeclared-resource silent skip — `_get_allowed_epochs` short-circuited
   the resource check when `NetConfig.resources` was empty, ignoring
   undeclared node-level `resources` keys. Validate at Net construction
   that every node-level resource name is declared in
   `NetConfig.resources`; remove the now-unreachable runtime guard.

Adds regression tests for each fix:
- test_ctx_state_persists_across_retries_multiprocess
- test_unpicklable_exception_does_not_hang
- test_validate_reports_resolution_failure (updated to assert exit≠0)
- test_depends_on_disabled_node_warns
- test_resources_undefined_raises (updated for construction-time check)

Tests: netrun 1308/1308 (was 1305 + 3 new), netrun-cli 61/61.
Samples 14/15/16/17 run cleanly on the fix branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@lukastk lukastk merged commit 33d5219 into main May 2, 2026
19 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant