Skip to content

feat(workflow): durable DAG workflow worker - #380

Merged
andersonleal merged 2 commits into
mainfrom
feat/workflow-worker
Jun 30, 2026
Merged

feat(workflow): durable DAG workflow worker#380
andersonleal merged 2 commits into
mainfrom
feat/workflow-worker

Conversation

@andersonleal

@andersonleal andersonleal commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

A new workflow worker that orchestrates multi-agent pipelines as a durable directed-acyclic graph on the iii engine.

  • DAG core — typed node/run state, dependency validation, multi-input joins, topological scheduling, and crash-safe durable persistence with reconcile.
  • Execution — per-node (optionally nested) agent sessions, fast-wake on turn-completed, caller notify callbacks, and a sweep loop with timeouts.
  • Permissions — inherit-by-default node permissions, per-node router deny.
  • Fire-and-forget workflow::start — callers get the run_id back immediately and receive the outcome via reply_to / notify or by polling workflow::status; no blocking await path.
  • Workspace picker control plane for the shell (feat(shell): add workspace picker control plane #376).
  • Wire llm-router and workflow deps into iii.worker.yaml / workflow.yaml; remove outdated observability and workflow plan docs.

Correctness model

Single-writer-per-run: every path that reads-mutates-writes a run record (tick, reconcile, sweep, stop) holds the per-run lock, closing the read-modify-write race. Idempotency for duplicate deliveries comes from deterministic child-session and node-result ids. Horizontal scaling is a deployment choice (shard tick by run_id) — no record-level CAS exists in iii-state yet, so true multi-writer-per-run is intentionally out of scope until an engine if_match/state::cas op lands.

Test plan

  • workflow/tests/orchestration.rs (~800 lines) covers DAG scheduling, joins, persistence/reconcile, and timeout sweep.
  • cargo test -p workflow

See workflow/README.md for the full result-delivery and scaling notes.

Summary by CodeRabbit

  • New Features

    • Added a new workflow worker with configurable orchestration, status tracking, stop/wake actions, and completion callbacks.
    • Workflow runs now support fan-out, joins, retries, timeouts, and crash-resume behavior.
    • Added run status and node result endpoints for better visibility into workflow progress.
  • Bug Fixes

    • Improved handling for failed, cancelled, and timed-out workflow steps.
    • Added safeguards for duplicate deliveries and stale updates.
  • Documentation

    • Added workflow worker setup and behavior documentation.

A new `workflow` worker that orchestrates multi-agent pipelines as a durable
directed-acyclic graph on the iii engine.

- DAG core: typed node/run state, dependency validation, multi-input joins,
  topological scheduling, and crash-safe durable persistence with reconcile.
- Execution: per-node (optionally nested) agent sessions, fast-wake on
  turn-completed, caller notify callbacks, and a sweep loop with timeouts.
- Permissions: inherit-by-default node permissions, per-node router deny.
- Fire-and-forget `workflow::start`: callers get the run_id back immediately
  and receive the outcome via `reply_to` / `notify` or by polling
  `workflow::status`; no blocking `await` path.
- Workspace picker control plane for the shell (#376).
- Add `llm-router` and `workflow` dependencies to iii.worker.yaml and
  workflow.yaml; remove outdated observability and workflow plan docs.

Claude-Session: https://claude.ai/code/session_019pMG6VY8cKLW2EzF1FEkV9
@vercel

vercel Bot commented Jun 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workers Ready Ready Preview, Comment Jun 30, 2026 5:16pm
workers-tech-spec Ready Ready Preview, Comment Jun 30, 2026 5:16pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 27 skipped (no docs/).

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

@coderabbitai

coderabbitai Bot commented Jun 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@andersonleal, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 12 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4a5d1722-0e2c-49ce-a318-ae0ed8dd9fdf

📥 Commits

Reviewing files that changed from the base of the PR and between 0cd0791 and 581fa93.

📒 Files selected for processing (11)
  • workflow/README.md
  • workflow/src/configuration.rs
  • workflow/src/events.rs
  • workflow/src/functions/inject_guidance.rs
  • workflow/src/functions/node_result.rs
  • workflow/src/functions/start.rs
  • workflow/src/functions/tick.rs
  • workflow/src/locks.rs
  • workflow/src/main.rs
  • workflow/src/state.rs
  • workflow/src/types.rs
📝 Walkthrough

Walkthrough

Introduces a new workflow Rust worker crate implementing a deterministic, crash-resumable DAG orchestrator over the iii harness. Adds all project scaffolding, core data types, state persistence, DAG scheduling/reconciliation/timeout engine, configuration with hot-reload, OpenTelemetry telemetry, terminal event emission, and all function endpoints (start, tick, stop, sweep, wake, status, node-result, plus stamp_reply and inject_guidance hooks). Registers the worker as a harness dependency.

Changes

workflow worker: new deterministic DAG orchestrator

Layer / File(s) Summary
Project scaffolding and harness registration
harness/iii.worker.yaml, workflow/.gitignore, workflow/Cargo.toml, workflow/iii.worker.yaml, workflow/src/lib.rs, workflow/src/manifest.rs, workflow/README.md
Adds Cargo workspace/package definition, build targets, dependencies, worker YAML, module declarations, ModuleManifest builder, README, and registers workflow+llm-router as harness dependencies.
Core types, errors, IDs, and per-run locks
workflow/src/types.rs, workflow/src/error.rs, workflow/src/ids.rs, workflow/src/locks.rs
Defines all public data model types (RunStatus, NodeState, WorkflowDef, WorkflowRunRecord, NodeCheckpoint, etc.), WorkflowError with iii_sdk conversion, opaque ID/key helpers, and WorkflowLocks for per-run in-process serialization using Tokio async mutexes.
State persistence layer
workflow/src/state.rs
Implements iii-state RPC wrappers with scope constants, a global atomic dispatch timeout, and all CRUD operations for run records, definitions, node results, session reverse-index, and idempotency keys; tolerates multiple JSON container shapes in list responses.
DAG engine, reconciliation, and timeout
workflow/src/dag.rs, workflow/src/reconcile.rs, workflow/src/timeout.rs
Implements pure DAG scheduling functions (fanout expansion, dependency resolution, frontier computation, input gathering, quiescence derivation, cycle validation), async node-result reconciliation polling harness status with result-size cap, and TimeoutAction sweep-time decision with backward-clock safety.
Configuration, hot-reload, and telemetry
workflow/src/config.rs, workflow/src/configuration.rs, workflow/src/telemetry.rs
Defines WorkerConfig with JSON schema/defaults, async config registration/fetch/apply with retry backoff, idempotent harness-hook binding orchestration using an AtomicBool and event-driven registry callbacks, cron sweep rebinding, config-change hot-reload trigger, and OpenTelemetry counters/histograms/gauge for run and node lifecycle metrics.
Terminal event emission
workflow/src/events.rs
Implements emit_run_completed (untargeted broadcast), emit_notify (durable at-least-once callback enqueue), and emit_reply (harness::send with deterministic idempotency key and reach/dispatch policy).
workflow::start: validation, idempotency, run creation
workflow/src/functions/start.rs
Implements StartRequest with custom Deserialize aggregating all structural errors, structural shape validator, semantic DAG validator (agent/fanout/join/depends-on/acyclicity rules), static fanout type proof, model-catalog validation via router::models::list, idempotency key scoping, sub-workflow nesting depth bounding, run record persistence, and initial tick enqueue.
workflow::tick: orchestration loop and finalization
workflow/src/functions/tick.rs
Implements TickDecision (Finalize/Fire/Park), pure decide, function-policy normalization with always-on router::chat/router::complete deny, fire_node building harness::send payloads with input fencing, finalize computing run result/failure summary/cascade-stop/checkpoint-cancellation/concurrent terminal events, and handle with stale-tick monotonic guard.
Remaining handlers, hooks, Deps wiring, and binary entrypoint
workflow/src/functions/..., workflow/src/main.rs
Implements stop, sweep (timeout+GC), wake, status, node-result, stamp_reply, inject_guidance handlers; wires Deps container and register_all; implements main() with crash-recovery re-enqueue, config-change trigger binding, and graceful shutdown.
End-to-end orchestration tests
workflow/tests/orchestration.rs
In-memory test suite covering 3-node fanout barrier ordering, redelivery idempotency, failure propagation, diamond join sequencing, abort precedence, empty fanout completion, and sweep timeout refire/failout behavior.

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant workflow_start
  participant workflow_tick
  participant harness
  participant iii_state
  participant events

  Caller->>workflow_start: StartRequest{definition, input}
  workflow_start->>iii_state: get_idem (idempotency check)
  workflow_start->>iii_state: put_run (Running record)
  workflow_start->>workflow_tick: enqueue_tick(step=0)

  loop each tick
    workflow_tick->>iii_state: get_run
    workflow_tick->>harness: harness::status (reconcile running nodes)
    workflow_tick->>iii_state: put_node_result (on completion)
    workflow_tick->>workflow_tick: expand_ready_fanouts + decide
    alt Fire nodes
      workflow_tick->>harness: harness::send (fire_node)
      workflow_tick->>iii_state: put_run
    else Finalize
      workflow_tick->>harness: cascade_stop_running
      workflow_tick->>events: emit_run_completed + emit_notify + emit_reply
      workflow_tick->>iii_state: put_run (terminal status)
    else Park
      workflow_tick->>iii_state: put_run
    end
  end

  rect rgba(200, 100, 50, 0.5)
    Note over workflow_tick,iii_state: sweep: reconcile + timeout + GC
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • iii-hq/workers#338: The new workflow crate is pinned to iii-sdk/iii-helpers =0.20.0, and this PR bumped those crates to 0.20.0 with related harness worker dependency changes.
  • iii-hq/workers#288: Both PRs modify harness/iii.worker.yaml's dependencies block by adding/aligning llm-router and other worker modules.
  • iii-hq/workers#134: Both PRs directly modify the harness/iii.worker.yaml dependencies block and could conflict at the manifest dependency declaration level.

Suggested reviewers

  • sergiofilhowz
  • ytallo

Poem

🐇 A rabbit hops through nodes and arcs,
Fanning out through DAG-lit parks,
Each tick a step, each run a quest,
State persisted — idempotent zest!
When all nodes done, the run completes,
emit_reply sends out the beats. 🎉

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a durable DAG workflow worker in the workflow package.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/workflow-worker

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🧹 Nitpick comments (4)
workflow/src/functions/start.rs (1)

896-908: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Persist the def after the depth check to avoid an orphaned def_ref.

put_def (Line 899) runs before caller_workflow_depth and the MAX_WORKFLOW_DEPTH rejection (Lines 903-908). When the depth cap trips, the function returns Err but the definition is already persisted with no corresponding run record, so nothing references it and run-scoped GC won't reclaim it. caller_workflow_depth only needs caller_session_id, so it can run before put_def.

♻️ Reorder depth check before persisting the def
     let run_id = new_run_id();
     let _guard = deps.locks.guard(&run_id).await;
 
-    state::put_def(&deps.iii, &run_id, &req.definition).await?;
-
     // Bound sub-workflow nesting: a node that opted into `workflow::start` could
     // otherwise recurse (sub-workflow → node → sub-workflow → …) without limit.
     let depth = caller_workflow_depth(deps, caller_session_id.as_deref()).await?;
     if depth > MAX_WORKFLOW_DEPTH {
         return Err(WorkflowError::InvalidDef(format!(
             "sub-workflow nesting depth {depth} exceeds the cap of {MAX_WORKFLOW_DEPTH}"
         )));
     }
+
+    state::put_def(&deps.iii, &run_id, &req.definition).await?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workflow/src/functions/start.rs` around lines 896 - 908, The depth-cap
validation in start workflow should happen before persisting the definition,
because `state::put_def` currently runs before `caller_workflow_depth` and can
leave an orphaned `def_ref` if `MAX_WORKFLOW_DEPTH` is exceeded. Move the
`caller_workflow_depth(deps, caller_session_id.as_deref())` check ahead of
`state::put_def` in `start`, then only store the def after the depth passes so
rejected sub-workflows do not create unreferenced persisted data.
workflow/tests/orchestration.rs (3)

171-186: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the classifier’s failure message, not just the variant.

This helper re-injects error into the checkpoint after only checking NodeOutcome::Failed(_), so a regression where classify_terminal("completed", ..., Some(err)) rewrites or drops the message would still pass here. Destructure NodeOutcome::Failed(msg) and persist/assert msg instead.

Proposed change
 fn complete_with_error(record: &mut WorkflowRunRecord, node_uid_str: &str, error: &str) {
-    // classify_terminal("completed", garbage_value, error) → must be Failed
-    let outcome = classify_terminal(
+    let outcome = classify_terminal(
         "completed",
         Some(json!({"unexpected": "garbage"})),
         Some(error.to_string()),
     );
-    assert!(
-        matches!(outcome, NodeOutcome::Failed(_)),
-        "classify_terminal should return Failed when result_error is set"
-    );
+    let msg = match outcome {
+        NodeOutcome::Failed(msg) => msg,
+        other => panic!("expected Failed, got {other:?}"),
+    };
+    assert_eq!(msg, error, "classify_terminal should preserve result_error");
 
     if let Some(cp) = record.nodes.get_mut(node_uid_str) {
         cp.state = NodeState::Failed;
-        cp.result_error = Some(error.to_string());
+        cp.result_error = Some(msg);
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workflow/tests/orchestration.rs` around lines 171 - 186, Update the test
helper complete_with_error in workflow/tests/orchestration.rs so it verifies the
actual failure payload from classify_terminal, not just that the result is
NodeOutcome::Failed. Destructure the NodeOutcome::Failed message returned by
classify_terminal("completed", ..., Some(error.to_string())) and assert it
matches the expected error string before storing it in WorkflowRunRecord; keep
the checkpoint update on cp.result_error aligned with that same asserted
message.

614-616: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The “non-empty ready frontier” case is not actually exercised here.

With abort = true, this record never reaches a materialized ready node: first plan is still Running, and later you call decide after plan completes without expanding the read fanout. So this test validates “abort beats running/quiescence”, but not “abort beats a ready frontier” as the comment says.

Proposed change
-    // decide must return Finalize(Cancelled) regardless of the Running node or
-    // any ready frontier.
+    // decide must return Finalize(Cancelled) regardless of the Running node.
     let decision = decide(&def, &record);
@@
-    // Simulate completing plan with a result (abort still set) — decide must
-    // still return Finalize(Cancelled), not Fire or Park.
+    // Materialize the next ready fanout, then assert abort still wins over a
+    // non-empty frontier.
     complete(&mut record, &mut results, "plan", json!({"docs": ["x"]}));
+    dag::expand_ready_fanouts(&def, &mut record, &results);
+    assert!(
+        !dag::ready_frontier(&def, &record).is_empty(),
+        "sanity: abort precedence check should have a ready frontier"
+    );
     let decision2 = decide(&def, &record);

Also applies to: 633-636

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workflow/tests/orchestration.rs` around lines 614 - 616, The abort test in
decide() is only covering Running/quiescent states, not a non-empty ready
frontier, so the comment is misleading. Update the orchestration test around
decide and the related read fanout setup so a materialized ready node is
actually present before calling decide, while keeping the abort=true assertion;
this should exercise the ready-frontier path in addition to the existing Running
case. Use the existing plan, record, and decide helpers in orchestration.rs to
build the frontier explicitly rather than relying on the current sequence.

553-567: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert the join payload before completing d.

This test currently proves the barrier semantics, but not the InputFrom::Many contract. Because drive_step never calls dag::gather_input, d still fires here even if the join payload starts dropping one branch or uses the wrong object shape. Add an assertion that dag::gather_input(&def, &record, "d", &results) contains both b and c keyed results before completing d.

Proposed change
     // Step 3: both b and c done → d's join fires.
     let step3 = drive_step(&def, &mut record, &results);
     match &step3 {
         TickDecision::Fire(uids) => {
             assert_eq!(
                 uids,
                 &vec!["d".to_string()],
                 "step 3 must fire d after join"
             );
         }
         other => panic!("expected Fire([d]) at step 3, got {:?}", other),
     }
+
+    let joined = dag::gather_input(&def, &record, "d", &results);
+    assert_eq!(
+        joined,
+        json!({
+            "b": {"x": 2},
+            "c": {"x": 3}
+        }),
+        "d must receive both branch results keyed by dependency id"
+    );
 
     // Complete d.
     complete(&mut record, &mut results, "d", json!({"x": 4}));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@workflow/tests/orchestration.rs` around lines 553 - 567, The test around
drive_step and complete for node d only checks that the join fires, but it does
not verify the InputFrom::Many payload shape. Before completing d, call
dag::gather_input with def, record, "d", and results, then assert the returned
payload includes both b and c keyed results with the expected object structure.
Keep the existing fire assertion in orchestration.rs, but add the payload
assertion in the same test so the join contract is validated directly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@workflow/README.md`:
- Around line 79-95: The iii-state concurrency note is hard-coded to sdk 0.19.2
while the workspace pins iii-sdk via the workflow crate, so update the README
section to match the pinned iii-sdk version or remove the explicit version
reference entirely. Edit the iii-state/WorkflowRunRecord explanation and the
“Blocked prerequisite” note so the rationale stays aligned with the actual
dependency version in workflow/Cargo.toml and does not go stale.

In `@workflow/src/configuration.rs`:
- Around line 186-208: The harness-hook registration path in the configuration
callback marks `bound` as true before `bind_turn_completed`,
`bind_pre_trigger_hook`, and `bind_pre_generate_hook` succeed, which blocks all
future retries if any bind fails. Move the `bound` transition so it only happens
after all three binds succeed, or otherwise reset it when a bind is
missing/failed, and keep the existing logging in the `configuration`
registration logic to report partial binding state. Use the `bound` atomic and
the three `bind_*` helpers as the key points to update so transient failures can
be retried on later `engine::*-available` events.
- Around line 395-400: The sweep schedule update in apply_config is being
committed even when the cron rebind does not succeed, causing the live snapshot
to drift from the active trigger. Update the workflow configuration path around
rebind_slot/bind_sweep and apply_config so the new sweep_expression is only
written after a successful rebind, and skip or preserve the old value when
bind_sweep returns None or fails. Keep the existing old.sweep_expression
comparison and tracing::info flow, but gate the snapshot commit on the rebind
outcome so later config changes can retry correctly.

In `@workflow/src/dag.rs`:
- Around line 397-398: The fanout path in dag::gather_input is currently
returning record.input.clone() for fanout_item, so read#i children get the
top-level input instead of the materialized fanout element. Update gather_input
to resolve fanout_item from record.fanout_src using the item’s uid/index, and
adjust the tick.rs caller so it passes the materialized uid rather than only the
base id. Make sure the dispatch input for fanout children is built from the
per-item materialized value, not the workflow input.

In `@workflow/src/events.rs`:
- Around line 98-106: The request payload built in the reply path currently
places `run` at the top level, but `harness::send` only reads
`SendRequest.options`, so the wake/passive flag is being ignored. Update the
payload construction in this reply flow to put `run` inside `options` alongside
`functions` before calling `harness::send`, using the existing `reply.functions`
and `wake` values so the handler receives the intended `run:true/false`
behavior.

In `@workflow/src/functions/inject_guidance.rs`:
- Around line 17-21: Update WORKFLOW_GUIDANCE in inject_guidance so it matches
the real default node policy enforced by tick::node_functions and
normalize_functions: remove or soften the claim that omitting agent.functions
means a node can call ["*"], and instead describe the actual inherited/allowed
behavior including the implicit control-plane and router-generate denials. Keep
the guidance aligned with the runtime rules so the wording around
agent.functions, default inheritance, and least-privilege examples in
WORKFLOW_GUIDANCE does not promise capabilities that the workflow engine will
reject.

In `@workflow/src/functions/node_result.rs`:
- Around line 25-30: The NodeResultResponse serialization is omitting the result
field when it is None because of the skip_serializing_if on the result member,
which breaks the expected explicit null response. Update NodeResultResponse so
workflow::node-result always serializes result as null when absent, and remove
or adjust the serde attribute on result to preserve the public contract.

In `@workflow/src/functions/start.rs`:
- Around line 564-601: The dependency validation in start workflow setup allows
node refs in input.from without matching depends_on, so scheduling and reads can
diverge. Update the validation around the node/dependency check to require every
node referenced by InputFrom::One/InputFrom::Many (and any fanout.over) to also
appear in depends_on, or otherwise reject the definition when they are missing.
Use the existing consumed-set logic in the node loop to compare against
depends_on and extend the InvalidDef message to point at the mismatch.

In `@workflow/src/functions/sweep.rs`:
- Around line 65-76: GC sweep should also clean up the workflow session reverse
index, not just the run record. Update the run deletion path used by sweep,
especially state::delete_run() and any helper it calls, to remove each
checkpoint’s session_id -> run_id entry before deleting the run. Use the
existing workflow state structures around state::delete_run and the
workflow::wake index maintenance to locate the session index cleanup, and ensure
the sweep loop in sweep() continues to call the same deletion path so orphaned
index rows cannot remain.

In `@workflow/src/functions/tick.rs`:
- Around line 128-136: The build_opening helper is embedding input_json directly
inside the workflow_input fence, which lets upstream content break out of the
boundary if it contains a closing tag. Update build_opening in tick.rs to escape
or neutralize any workflow_input tag delimiters in the payload before formatting
the fenced string, while keeping the structure of build_opening and its template
handling unchanged.

In `@workflow/src/locks.rs`:
- Around line 19-33: The per-run mutex cache in WorkflowLocks::guard leaves
strong Arc entries in the map forever, so completed run_ids are never reclaimed.
Update the WorkflowLocks map/guard flow to avoid retaining strong ownership
after a lock is no longer needed, either by storing Weak references or by
removing the entry once the last guard is dropped. Make the change around
WorkflowLocks::guard and the map field so stale run entries can be evicted
safely without breaking concurrent lock acquisition.

In `@workflow/src/main.rs`:
- Around line 93-96: The startup path in main currently aborts on any
fetch_config() error, but worker binaries should stay available by warning and
falling back to WorkerConfig::default(). Update the config-loading block around
configuration::fetch_config(&iii) to catch errors, emit tracing::warn! with
enough context, and continue using WorkerConfig::default() instead of
propagating the failure through anyhow::Error::msg/context.

In `@workflow/src/state.rs`:
- Around line 171-177: The cleanup logic in state::delete_record is dropping
errors from state_delete for child results and the definition, then always
deleting the run record via state_delete on SCOPE_RUN. Update this flow so the
run record is only removed after all child deletes succeed: propagate or
aggregate failures from the loop over record.nodes and the SCOPE_DEF delete, and
return early on any error before calling the final run delete. Use the existing
state_delete, SCOPE_RESULT, SCOPE_DEF, SCOPE_RUN, and def_key symbols to keep
the retry anchor intact.

In `@workflow/src/types.rs`:
- Around line 55-58: The node id constraint in the types definition is missing
`/`, which breaks the `<run_id>/<node_uid>` storage contract used by result
references. Update the node id validation near the `nodes: BTreeMap<String,
NodeDef>` definition and any related checks in `ids.rs`/`result_ref` handling to
reject `/` as well, or consistently escape node ids before composing storage
keys so `node_uid` round-trips safely.

---

Nitpick comments:
In `@workflow/src/functions/start.rs`:
- Around line 896-908: The depth-cap validation in start workflow should happen
before persisting the definition, because `state::put_def` currently runs before
`caller_workflow_depth` and can leave an orphaned `def_ref` if
`MAX_WORKFLOW_DEPTH` is exceeded. Move the `caller_workflow_depth(deps,
caller_session_id.as_deref())` check ahead of `state::put_def` in `start`, then
only store the def after the depth passes so rejected sub-workflows do not
create unreferenced persisted data.

In `@workflow/tests/orchestration.rs`:
- Around line 171-186: Update the test helper complete_with_error in
workflow/tests/orchestration.rs so it verifies the actual failure payload from
classify_terminal, not just that the result is NodeOutcome::Failed. Destructure
the NodeOutcome::Failed message returned by classify_terminal("completed", ...,
Some(error.to_string())) and assert it matches the expected error string before
storing it in WorkflowRunRecord; keep the checkpoint update on cp.result_error
aligned with that same asserted message.
- Around line 614-616: The abort test in decide() is only covering
Running/quiescent states, not a non-empty ready frontier, so the comment is
misleading. Update the orchestration test around decide and the related read
fanout setup so a materialized ready node is actually present before calling
decide, while keeping the abort=true assertion; this should exercise the
ready-frontier path in addition to the existing Running case. Use the existing
plan, record, and decide helpers in orchestration.rs to build the frontier
explicitly rather than relying on the current sequence.
- Around line 553-567: The test around drive_step and complete for node d only
checks that the join fires, but it does not verify the InputFrom::Many payload
shape. Before completing d, call dag::gather_input with def, record, "d", and
results, then assert the returned payload includes both b and c keyed results
with the expected object structure. Keep the existing fire assertion in
orchestration.rs, but add the payload assertion in the same test so the join
contract is validated directly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bf8830af-1161-4a5b-9325-b9620f37633e

📥 Commits

Reviewing files that changed from the base of the PR and between c5f4217 and 0cd0791.

⛔ Files ignored due to path filters (1)
  • workflow/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (31)
  • harness/iii.worker.yaml
  • workflow/.gitignore
  • workflow/Cargo.toml
  • workflow/README.md
  • workflow/iii.worker.yaml
  • workflow/src/config.rs
  • workflow/src/configuration.rs
  • workflow/src/dag.rs
  • workflow/src/error.rs
  • workflow/src/events.rs
  • workflow/src/functions/inject_guidance.rs
  • workflow/src/functions/mod.rs
  • workflow/src/functions/node_result.rs
  • workflow/src/functions/stamp_reply.rs
  • workflow/src/functions/start.rs
  • workflow/src/functions/status.rs
  • workflow/src/functions/stop.rs
  • workflow/src/functions/sweep.rs
  • workflow/src/functions/tick.rs
  • workflow/src/functions/wake.rs
  • workflow/src/ids.rs
  • workflow/src/lib.rs
  • workflow/src/locks.rs
  • workflow/src/main.rs
  • workflow/src/manifest.rs
  • workflow/src/reconcile.rs
  • workflow/src/state.rs
  • workflow/src/telemetry.rs
  • workflow/src/timeout.rs
  • workflow/src/types.rs
  • workflow/tests/orchestration.rs

Comment thread workflow/README.md Outdated
Comment thread workflow/src/configuration.rs
Comment thread workflow/src/configuration.rs Outdated
Comment thread workflow/src/dag.rs
Comment thread workflow/src/events.rs
Comment thread workflow/src/functions/tick.rs
Comment thread workflow/src/locks.rs
Comment thread workflow/src/main.rs Outdated
Comment thread workflow/src/state.rs
Comment thread workflow/src/types.rs Outdated
- start: fold each node's input.from/fanout.over reads into depends_on before
  validate+persist, so a node never schedules before a node it reads (was: the
  read resolved to null while the run still reported success); reserve '/' in
  node ids too (it's the run_id/node_uid storage-key separator).
- state(delete_run): propagate child deletes so the run record survives as the
  retry anchor on a transient failure, and clear the session reverse-index so GC
  stops leaking index rows.
- locks: hold Weak (not Arc) per-run mutexes so a finished run's lock frees
  instead of pinning forever.
- configuration: release the harness-hook bind claim on a partial bind so a
  later registry-change event retries; commit a new sweep_expression only after
  the cron rebind actually succeeds.
- tick(build_opening): escape angle brackets in the untrusted fenced input so
  upstream output can't forge a </workflow_input> break-out.
- events(reply): drop the dead top-level `run` field — harness::send has no such
  field (verified against harness send.rs) and always drives a turn; the captured
  caller policy already rides in options.functions.
- node-result: serialize an explicit `{ "result": null }` instead of dropping
  the key, matching the documented contract.
- main: warn and fall back to WorkerConfig::default() on a config-fetch error
  instead of aborting boot (matches the other worker binaries).
- inject_guidance: correct the default-node-policy wording (reach minus the
  workflow control plane), and README: sdk 0.19.2 -> 0.20.0 (pinned iii-sdk).
@andersonleal
andersonleal merged commit fb10955 into main Jun 30, 2026
16 checks passed
@andersonleal
andersonleal deleted the feat/workflow-worker branch June 30, 2026 17:24
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.

2 participants