Skip to content

feat(flows): host DedupCommitSubscriber for the exactly-once dedup node - #5265

Merged
graycyrus merged 7 commits into
tinyhumansai:mainfrom
graycyrus:feat/flows-dedup-node
Jul 29, 2026
Merged

feat(flows): host DedupCommitSubscriber for the exactly-once dedup node#5265
graycyrus merged 7 commits into
tinyhumansai:mainfrom
graycyrus:feat/flows-dedup-node

Conversation

@graycyrus

@graycyrus graycyrus commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Host half of the exactly-once dedup node (issue #5263). Depends on tinyhumansai/tinyflows#25 (engine — the dedup filter + StateStore contract); the submodule gitlink is bumped to that branch and must re-point to the squash-merge SHA before this goes green. Pairs with #5264 (frontend).

What

  • DedupCommitSubscriber (flows/bus.rs) on FlowRunFinished: enumerates dedup nodes from the flow graph, then per node — success (completed/completed_with_warnings): union tentativecommitted, delete tentative; anything else (failed/cancelled/interrupted/unknown — fail-closed): delete tentative only. So a failed run never marks items done → they retry. Reuses the engine's exact flow:<id> namespace + imported committed_key/tentative_key (no drift). Registered in the DomainGroup::Flows startup block; flows leaf-gate.
  • flows::store::kv_delete — real DELETE on flow_state.
  • node_contracts / tools / builder_tools — 13→14 kind bumps + dedup host overlay (run-level commit semantics + canonical split_out → dedup → action placement). The memory overlay's "deferred to a dedicated primitive" note now redirects to the dedup node.
  • builder prompt — teaches the dedup node (one node, keyed on a stable id, placed before the action); removes the feat(flows): host adapter for the memory node — in-graph memory read + flow-scoped write #5227-era deferral.
  • about_appautomation.flow_dedup_node entry.

Commit granularity

Run-level (the engine has no per-item success isolation). True exactly-once for single-action flows; at-least-once (safe: retry) for per-item side-effects. Documented.

Testing

dedup_commit_* in bus.rs (union-on-success, completed_with_warnings success, release-on-failure/cancel/interrupt, two independent nodes) + node_contracts (overlay_preserves_all_14_kinds, dedup overlay, memory→dedup redirect) + list_node_kinds_tool_returns_all_fourteen. cargo check/722 tests/fmt/clippy all clean (GGML_NATIVE=OFF).

Summary by CodeRabbit

  • New Features
    • Added a dedup workflow node for exactly-once per-item processing via config.key, committing on successful runs (including warnings) and leaving uncommitted for retries after failures/cancellation.
    • Updated workflow builder/tooling, node catalogs, and DSL guidance to support dedup (14 node types total), including the new automation.flow_dedup_node capability.
  • Bug Fixes
    • Improved dedup commit settlement so per-flow updates apply reliably and safely tolerate missing/malformed dedup state.
  • Tests
    • Expanded coverage for dedup commit/release behavior and per-flow ordering guarantees.

Implements the host side of the tinyflows `dedup` node (PR2, issue tinyhumansai#5263):
`DedupCommitSubscriber` in `flows::bus`, listening for `FlowRunFinished` and
settling every `dedup` node in the finished flow's graph via the same
per-flow `StateStore` namespace (`"flow:<id>"`) the engine's `dedup` node
itself reads/writes through.

- On success (`completed`/`completed_with_warnings`): unions each dedup
  node's `tentative` key set into `committed`, then clears `tentative`.
- On failure/cancel/interrupt (or any unrecognized status — fail closed
  toward "not committed"): clears `tentative` only, leaving `committed`
  untouched so the released items retry next run.
- Node ids are derived from the flow's saved graph (`kind == "dedup"`) since
  `StateStore` has no prefix-scan. Registered alongside `FlowTriggerSubscriber`
  / `FlowRunDigestSubscriber` in the same `DomainGroup::Flows` startup block.
- `flows::store::kv_delete` added next to `kv_get`/`kv_set` to clear
  `tentative` (rather than writing `[]`).
- `node_contracts.rs` gains the `dedup` kind's host overlay (run-level commit
  semantics, canonical split_out → dedup → action placement); the memory
  overlay's dedup deferral note now redirects to the dedup node instead. The
  13->14 kind-count drift tests (node_contracts, propose_workflow
  description, list_node_kinds) are updated in lockstep.
- workflow_builder prompt.md teaches the `dedup` node as THE way to do
  "process each item once", replacing the tinyhumansai#5227 "deferred to a dedicated
  primitive" notes; the guard test asserting that deferral is inverted to
  assert the dedup node is taught instead.
- `about_app` catalog gains a Dedup Node (Flows) capability entry.
- vendor/tinyflows gitlink bumped to c81a4cd (dedup node kind + filter half,
  not modified here — see PR1).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d4827552-c912-4050-8e0c-fad6d8505601

📥 Commits

Reviewing files that changed from the base of the PR and between 03a9471 and 49dc54b.

📒 Files selected for processing (1)
  • src/openhuman/flows/builder_tools.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/openhuman/flows/builder_tools.rs

📝 Walkthrough

Walkthrough

Adds a dedup workflow node with host-side commit-on-success handling, per-flow serialized state settlement, updated storage support, builder tooling, contracts, capability metadata, documentation, tests, and event-bus registration.

Changes

Dedup workflow node

Layer / File(s) Summary
Dedup settlement runtime
src/openhuman/flows/bus.rs, src/openhuman/flows/store.rs, vendor/tinyflows
Adds DedupCommitSubscriber, per-flow locking, tentative/committed key persistence, deletion support, and coverage for terminal statuses, multiple nodes, and concurrent runs.
Event-bus registration
src/core/jsonrpc.rs
Registers the dedup commit subscriber within the flows-domain event-bus bootstrap path and logs registration failures.
Node contracts and builder exposure
src/openhuman/flows/node_contracts.rs, src/openhuman/flows/tools.rs, src/openhuman/flows/builder_tools.rs, src/openhuman/flows/*_tests.rs
Adds dedup as the fourteenth node kind across contracts, schemas, descriptions, configuration hints, and tests.
Workflow guidance and capability metadata
src/openhuman/flows/agents/workflow_builder/prompt.md, src/openhuman/about_app/catalog_data.rs
Documents dedup placement, key configuration, commit/release semantics, and flow-scoped state handling.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant FlowRun as Flow run
  participant Subscriber as DedupCommitSubscriber
  participant Graph as Saved flow graph
  participant State as flow_state
  FlowRun->>Subscriber: FlowRunFinished
  Subscriber->>Graph: Find dedup nodes
  Subscriber->>State: Load tentative and committed keys
  Subscriber->>State: Commit union on success or clear tentative keys
Loading

Possibly related issues

  • tinyhumansai/openhuman#5263 — Directly covers the dedicated NodeKind::Dedup feature and its runtime, state, tooling, contracts, and documentation.
  • tinyhumansai/openhuman#5268 — Extends the dedup subscriber’s overlapping-run state handling and graph-snapshot settlement.
  • tinyhumansai/openhuman#5269 — Addresses the non-retryable domain-subscriber registration behavior documented in this change.

Possibly related PRs

Suggested labels: feature, rust-core

Poem

I’m a bunny with keys in a row,
Committing the ones that should stay so.
If the run fails, I let them all flee,
Then try again fresh as a leaf on a tree.
Dedup hops onward—one time, happily!

🚥 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 names the main flow change: adding the host-side DedupCommitSubscriber for the dedup node.
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.

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

…ows gitlink

- Per-flow-id async keyed lock (FLOW_COMMIT_LOCKS) around the commit
  read-modify-write, so overlapping FlowRunFinished events for the same flow
  can't clobber each other's committed set (closes the durable half of the
  StateStore race; residual node-side tentative race documented + deferred).
- Bump vendor/tinyflows gitlink to 0ce4fbc (adds the concurrency docs).
- Regenerate Cargo.lock + app/src-tauri/Cargo.lock: adds the futures/tokio
  entries tinyhumans-sdk needs — a pre-existing --locked drift from main's
  e213bc5 (gitlink bumped without regenerating the lock), which blocks CI
  for any PR branched off current main.
…dup commit

Cargo.lock / app/src-tauri/Cargo.lock picked up the pre-existing
tinyhumans-sdk futures/tokio lock drift (from main's e213bc5) the last
time `cargo check` ran locally without --locked. That drift is unrelated to
the dedup per-flow commit lock and the tinyflows gitlink bump (docs-only,
same tinyflows version) — revert both lockfiles back to their prior
committed state so this branch's diff stays scoped to the dedup fix.
…d CI)

The prior revert (24d3c3f) removed the lock regen assuming it was avoidable
drift, but `cargo metadata --locked` fails without it — main's e213bc5 bumped
the tinyhumans-sdk gitlink without regenerating either lockfile, so every PR off
current main must carry the futures/tokio lock entries to build --locked.
@graycyrus
graycyrus marked this pull request as ready for review July 29, 2026 14:05
@graycyrus
graycyrus requested a review from a team July 29, 2026 14:05
@coderabbitai coderabbitai Bot added feature Net-new user-facing capability or product behavior. rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. labels Jul 29, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f2532976b5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +697 to +701
fn commit(&self, namespace: &str, node_id: &str, flow_id: &str, run_id: &str) {
let tentative_key = dedup_node::tentative_key(node_id);
let committed_key = dedup_node::committed_key(node_id);

let tentative = load_key_set(&self.config, namespace, &tentative_key);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Scope tentative dedup keys to the finishing run

When two runs of the same flow overlap, this reads the node's shared tentative set and commits everything currently there for whichever run happens to finish successfully. A scheduled/app-event run can overlap with an explicit RPC/detached run, and the second run may have already written keys to the same dedup:<node>:tentative row before its downstream action finishes; if the first run then succeeds, those in-flight keys are marked committed even if the second run later fails or is cancelled, causing future runs to drop work that never completed. The host needs either per-run tentative buckets or a same-flow run exclusion around dedup flows before commit-on-success can be safe.

Useful? React with 👍 / 👎.

Comment on lines +638 to +642
match store::get_flow(&self.config, flow_id) {
Ok(Some(flow)) => flow
.graph
.nodes
.iter()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Settle the dedup nodes from the run snapshot

This loads the current saved flow definition at finish time, not the graph that the run actually executed. If a long-running flow is edited while it is in flight, deleting or renaming a dedup node before FlowRunFinished means the subscriber won't settle the tentative keys that the running graph wrote, so successful work is retried on the next run; conversely newly saved node ids can be settled for a run that never executed them. Persist or derive the dedup node ids from the prepared/run snapshot instead of the mutable flow definition.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (4)
src/openhuman/flows/bus.rs (4)

516-532: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Note the lock's process-local scope.

FLOW_COMMIT_LOCKS is a process-global static, so it serializes only within one core process; two processes sharing the same flow_state SQLite file (e.g. CLI plus sidecar, or an overlapping restart) can still interleave the read-modify-write. Worth stating that explicitly alongside the node-side limitation already documented here.

🤖 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 `@src/openhuman/flows/bus.rs` around lines 516 - 532, Update the commit
atomicity documentation around handle_finished and FLOW_COMMIT_LOCKS to
explicitly state that the process-global lock serializes settlements only within
a single core process. Document that separate processes sharing the same
flow_state SQLite file can still interleave the read-modify-write, alongside the
existing node-side limitation.

475-480: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

bus.rs is now well past the module-size guideline.

With DedupCommitSubscriber and its tests this file is ~1.7k lines hosting three unrelated subscribers. Consider splitting into bus/dedup_commit.rs (plus its own tests module) to keep single-responsibility modules.

As per coding guidelines: "Prefer Rust modules of approximately 500 lines or fewer and maintain small, single-responsibility Unix-style modules."

🤖 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 `@src/openhuman/flows/bus.rs` around lines 475 - 480, Split the
DedupCommitSubscriber implementation and its associated tests out of bus.rs into
a dedicated bus/dedup_commit.rs module. Update module declarations, imports, and
visibility as needed so the subscriber’s behavior and tests remain unchanged,
while bus.rs retains only the other subscriber responsibilities.

Source: Coding guidelines


677-691: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Blocking SQLite work runs on the async executor while the per-flow lock is held.

commit/release call synchronous rusqlite through store::kv_*, so the whole critical section blocks the runtime thread. With many dedup nodes this can stall other tasks on the same worker. Consider wrapping the settlement loop in tokio::task::spawn_blocking (or block_in_place), keeping the lock acquisition async.

🤖 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 `@src/openhuman/flows/bus.rs` around lines 677 - 691, The settlement loop under
the per-flow lock must not execute synchronous SQLite work on the async
executor. Keep lock acquisition and test-delay handling in the async flow, then
move the `commit`/`release` loop for `namespace` and `node_ids` into
`tokio::task::spawn_blocking` (or `block_in_place`), preserving the lock until
settlement completes and handling any blocking-task failure appropriately.

697-734: 🩺 Stability & Availability | 🔵 Trivial

committed set grows without bound.

Every successful run unions more keys into a single JSON array row that is fully read, deserialized, re-sorted, and rewritten on each settlement — O(n) per run with no eviction. For long-lived, high-volume flows this row will keep growing. Worth planning a bound (max size / TTL / per-key rows) before this ships widely.

🤖 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 `@src/openhuman/flows/bus.rs` around lines 697 - 734, Update the deduplication
flow around commit to bound retention of committed keys, using an explicit
maximum size, TTL, or per-key storage strategy instead of continually unioning
into one unbounded JSON array. Ensure load, persistence, and settlement behavior
consistently evicts or expires old entries while preserving deduplication for
retained keys; anchor the change in commit and the existing
load_key_set/store_key_set flow.
🤖 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 `@src/core/jsonrpc.rs`:
- Around line 2165-2184: Update the Flows registration logic surrounding
DedupCommitSubscriber::new and subscribe_global so a failed subscription does
not permanently mark DomainGroup::Flows as initialized. Record the group only
after subscribe_global returns Some and the handle is retained, or otherwise
ensure a None result remains retryable on later invocations while preserving the
existing warning.

In `@src/openhuman/flows/agents/workflow_builder/prompt.md`:
- Line 633: Update the fenced workflow example in prompt.md to include a
language identifier immediately after the opening fence, using text or the
repository’s supported DSL label so markdownlint MD040 passes.
- Around line 637-642: Update config.key guidance in
src/openhuman/flows/agents/workflow_builder/prompt.md lines 637-642 to state
that resolved keys may contain selected item-derived data, and recommend opaque
stable IDs rather than claiming they are never content. Update the corresponding
local-storage wording in src/openhuman/about_app/catalog_data.rs lines 1536-1544
to remove “never the item's content” and accurately describe that only the
resolved key is stored.

In `@src/openhuman/flows/node_contracts.rs`:
- Around line 86-92: Complete the terminal-status contract by documenting
unknown as a release status alongside failed, cancelled, and interrupted. Update
src/openhuman/flows/node_contracts.rs lines 86-92 and lines 195-202,
src/openhuman/flows/agents/workflow_builder/prompt.md lines 649-653, and
src/openhuman/about_app/catalog_data.rs lines 1539-1542; ensure the overlay
assertion explicitly verifies unknown is documented.

In `@src/openhuman/flows/tools.rs`:
- Around line 490-493: Update the NodeKind::Dedup branch to pass the formatted
“key: {k}” hint through truncate_hint, preserving the existing optional-value
behavior and MAX_CONFIG_HINT_CHARS bound. Add a regression test covering an
oversized config.key and asserting the returned dedup hint is truncated.

---

Nitpick comments:
In `@src/openhuman/flows/bus.rs`:
- Around line 516-532: Update the commit atomicity documentation around
handle_finished and FLOW_COMMIT_LOCKS to explicitly state that the
process-global lock serializes settlements only within a single core process.
Document that separate processes sharing the same flow_state SQLite file can
still interleave the read-modify-write, alongside the existing node-side
limitation.
- Around line 475-480: Split the DedupCommitSubscriber implementation and its
associated tests out of bus.rs into a dedicated bus/dedup_commit.rs module.
Update module declarations, imports, and visibility as needed so the
subscriber’s behavior and tests remain unchanged, while bus.rs retains only the
other subscriber responsibilities.
- Around line 677-691: The settlement loop under the per-flow lock must not
execute synchronous SQLite work on the async executor. Keep lock acquisition and
test-delay handling in the async flow, then move the `commit`/`release` loop for
`namespace` and `node_ids` into `tokio::task::spawn_blocking` (or
`block_in_place`), preserving the lock until settlement completes and handling
any blocking-task failure appropriately.
- Around line 697-734: Update the deduplication flow around commit to bound
retention of committed keys, using an explicit maximum size, TTL, or per-key
storage strategy instead of continually unioning into one unbounded JSON array.
Ensure load, persistence, and settlement behavior consistently evicts or expires
old entries while preserving deduplication for retained keys; anchor the change
in commit and the existing load_key_set/store_key_set flow.
🪄 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: dca0db01-c015-42ed-9075-8158b8cf6a36

📥 Commits

Reviewing files that changed from the base of the PR and between e213bc5 and f253297.

⛔ Files ignored due to path filters (2)
  • Cargo.lock is excluded by !**/*.lock
  • app/src-tauri/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (10)
  • src/core/jsonrpc.rs
  • src/openhuman/about_app/catalog_data.rs
  • src/openhuman/flows/agents/workflow_builder/prompt.md
  • src/openhuman/flows/builder_tools.rs
  • src/openhuman/flows/builder_tools_tests.rs
  • src/openhuman/flows/bus.rs
  • src/openhuman/flows/node_contracts.rs
  • src/openhuman/flows/store.rs
  • src/openhuman/flows/tools.rs
  • vendor/tinyflows

Comment thread src/core/jsonrpc.rs
Comment on lines +2165 to +2184
// Dedup commit-on-success (issue #5263 PR2): on every terminal
// `FlowRunFinished`, settles each `dedup` node in the flow's graph
// — unions tentative keys into committed on success, releases
// (clears) tentative on failure/cancel/interrupt. This is the
// host half of the `dedup` node's exactly-once contract; the node
// itself (`tinyflows::nodes::control_flow::dedup`) only ever
// reads `committed` and writes `tentative`. Registered in the
// same `group_first_time(DomainGroup::Flows)` block as the other
// two flows subscribers above — see the digest subscriber's
// comment just above for why a second `group_first_time` guard
// here would be redundant.
if let Some(handle) = crate::core::event_bus::subscribe_global(Arc::new(
crate::openhuman::flows::bus::DedupCommitSubscriber::new(Arc::new(config.clone())),
)) {
std::mem::forget(handle);
} else {
log::warn!(
"[event_bus] failed to register flows dedup-commit subscriber — bus not initialized"
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make failed subscriber registration retryable.

group_first_time(DomainGroup::Flows) records the group before this registration. If subscribe_global returns None, this only logs a warning; later invocations skip the whole Flows block, leaving DedupCommitSubscriber permanently absent and tentative keys unsettled. Mark the group only after successful registration or keep failed registrations retryable.

🤖 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 `@src/core/jsonrpc.rs` around lines 2165 - 2184, Update the Flows registration
logic surrounding DedupCommitSubscriber::new and subscribe_global so a failed
subscription does not permanently mark DomainGroup::Flows as initialized. Record
the group only after subscribe_global returns Some and the handle is retained,
or otherwise ensure a None result remains retryable on later invocations while
preserving the existing warning.

Comment thread src/openhuman/flows/agents/workflow_builder/prompt.md Outdated
Comment thread src/openhuman/flows/agents/workflow_builder/prompt.md
Comment thread src/openhuman/flows/node_contracts.rs Outdated
Comment thread src/openhuman/flows/tools.rs Outdated
Comment on lines +736 to +757
/// Failure path: clear `tentative` only, leaving `committed` untouched so
/// the released keys retry on the flow's next run.
fn release(&self, namespace: &str, node_id: &str, flow_id: &str, run_id: &str) {
let released =
load_key_set(&self.config, namespace, &dedup_node::tentative_key(node_id)).len();
if released == 0 {
tracing::trace!(target: "flows", %flow_id, %run_id, node_id, "[dedup-commit] no tentative keys — nothing to release");
return;
}
match store::kv_delete(&self.config, namespace, &dedup_node::tentative_key(node_id)) {
Ok(()) => tracing::debug!(
target: "flows", %flow_id, %run_id, node_id, released,
"[dedup-commit] released tentative keys — will retry next run"
),
Err(e) => tracing::warn!(
target: "flows", %flow_id, %run_id, node_id, error = %e,
"[dedup-commit] failed to release tentative — those keys remain tentative until \
a future successful commit reconciles them (harmless: committed stays untouched \
either way, so no item is ever wrongly marked done)"
),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Extra DB read in release() purely for log count

load_key_set does a full kv_get + JSON deserialization + HashSet construction just to obtain released for the trace/debug messages and the == 0 early-return guard. kv_delete already silently no-ops on a missing key, so the early-return is only an optimization. Because kv_delete returns Result<()> (the affected-row count is discarded), the count can't be recovered from the delete itself — but the simplest fix is to just skip the early-return and drop the count. If exact counts in the success log are valuable, the alternative is to change kv_delete to return Result<usize> and use the affected-row count directly.

Suggested change
/// Failure path: clear `tentative` only, leaving `committed` untouched so
/// the released keys retry on the flow's next run.
fn release(&self, namespace: &str, node_id: &str, flow_id: &str, run_id: &str) {
let released =
load_key_set(&self.config, namespace, &dedup_node::tentative_key(node_id)).len();
if released == 0 {
tracing::trace!(target: "flows", %flow_id, %run_id, node_id, "[dedup-commit] no tentative keys — nothing to release");
return;
}
match store::kv_delete(&self.config, namespace, &dedup_node::tentative_key(node_id)) {
Ok(()) => tracing::debug!(
target: "flows", %flow_id, %run_id, node_id, released,
"[dedup-commit] released tentative keys — will retry next run"
),
Err(e) => tracing::warn!(
target: "flows", %flow_id, %run_id, node_id, error = %e,
"[dedup-commit] failed to release tentative — those keys remain tentative until \
a future successful commit reconciles them (harmless: committed stays untouched \
either way, so no item is ever wrongly marked done)"
),
}
}
/// Failure path: clear `tentative` only, leaving `committed` untouched so
/// the released keys retry on the flow's next run.
fn release(&self, namespace: &str, node_id: &str, flow_id: &str, run_id: &str) {
match store::kv_delete(&self.config, namespace, &dedup_node::tentative_key(node_id)) {
Ok(()) => tracing::debug!(
target: "flows", %flow_id, %run_id, node_id,
"[dedup-commit] released tentative keys — will retry next run"
),
Err(e) => tracing::warn!(
target: "flows", %flow_id, %run_id, node_id, error = %e,
"[dedup-commit] failed to release tentative — those keys remain tentative until \
a future successful commit reconciles them (harmless: committed stays untouched \
either way, so no item is ever wrongly marked done)"
),
}
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

Greptile Summary

This PR implements the host half of the exactly-once dedup node contract (DedupCommitSubscriber) that pairs with the engine-side DedupNode already landed in vendor/tinyflows. On FlowRunFinished it settles every dedup node in the flow's graph: success unions tentative → committed and clears tentative; any non-success status clears tentative only, leaving items to retry. Overlapping FlowRunFinished events for the same flow are serialized through a per-flow_id async lock (FLOW_COMMIT_LOCKS) to close the durable read-modify-write race.

  • DedupCommitSubscriber (bus.rs) — full commit/release logic with per-flow lock, best-effort error handling (swallowed with warnings), and thorough unit tests including a concurrency proof for the lock.
  • kv_delete (store.rs) — new DELETE primitive used by the subscriber; correctly no-ops on a missing key.
  • Node contracts / tools / builder prompt — 13→14 kind bumps, dedup overlay documenting run-level commit semantics and canonical split_out → dedup → action placement, memory overlay redirected to the dedup node.

Confidence Score: 4/5

Safe to merge once the submodule re-points to the squash-merge SHA as noted in the PR description.

The commit/release logic is correct, the per-flow lock correctly serializes overlapping FlowRunFinished handlers, and best-effort error paths degrade to at-most-once retry rather than silent data loss. The only code issue is a minor double DB read in release() that exists purely for a log count. The known node-side race (concurrent runs writing to the same tentative row) is explicitly documented and deferred. The submodule gitlink must be re-pointed to the squash-merge SHA before green — that is the remaining gating concern, not a code defect.

Files Needing Attention: bus.rs carries all the new logic and deserves a second read; the release() double-load is the one rough edge. The submodule pointer in vendor/tinyflows must be verified before merge.

Important Files Changed

Filename Overview
src/openhuman/flows/bus.rs Core addition: DedupCommitSubscriber (~350 lines) + FLOW_COMMIT_LOCKS per-flow serialization. Logic, locking model, and error handling are sound; one minor double-load in release() for logging count.
src/openhuman/flows/store.rs Adds kv_delete — correct SQL DELETE with no-op on missing key, proper anyhow context wrapping.
src/core/jsonrpc.rs Registers DedupCommitSubscriber in the DomainGroup::Flows startup block, following the same pattern as the existing digest subscriber.
src/openhuman/flows/node_contracts.rs Adds dedup overlay with run-level commit semantics and canonical placement notes; memory overlay redirected to dedup; 13→14 kind bump with updated tests.
src/openhuman/flows/tools.rs Adds dedup to the ProposeWorkflowTool enum list and config_hint; count bump to 14 is consistent.
src/openhuman/flows/builder_tools.rs Mechanical 13→14 count bumps in description strings for ListNodeKindsTool and GetNodeKindContractTool.
src/openhuman/about_app/catalog_data.rs New automation.flow_dedup_node capability entry with accurate description and Beta status.
src/openhuman/flows/agents/workflow_builder/prompt.md Teaches the builder agent the dedup node, its placement pattern, and removes the #5227-era deferral note. Instruction is clear and accurate.

Sequence Diagram

sequenceDiagram
    participant Engine as DedupNode (engine)
    participant Store as flow_state KV
    participant Sub as DedupCommitSubscriber
    participant Bus as Event Bus

    Note over Engine,Store: During flow run
    Engine->>Store: "kv_get(dedup:id:committed)"
    Store-->>Engine: committed set (filter items)
    Engine->>Store: "kv_set(dedup:id:tentative, new keys)"

    Note over Bus,Sub: Flow run finishes
    Bus->>Sub: "FlowRunFinished {flow_id, status}"
    Sub->>Sub: acquire FLOW_COMMIT_LOCKS[flow_id]
    alt success (completed / completed_with_warnings)
        Sub->>Store: "kv_get(dedup:id:tentative)"
        Store-->>Sub: tentative set
        Sub->>Store: "kv_get(dedup:id:committed)"
        Store-->>Sub: committed set
        Sub->>Store: "kv_set(dedup:id:committed, union)"
        Sub->>Store: "kv_delete(dedup:id:tentative)"
    else failure (failed / cancelled / interrupted)
        Sub->>Store: "kv_delete(dedup:id:tentative)"
    end
    Sub->>Sub: release FLOW_COMMIT_LOCKS[flow_id]
Loading

Reviews (1): Last reviewed commit: "chore(flows): re-point tinyflows gitlink..." | Re-trigger Greptile

- privacy wording (prompt.md + about_app): a key expr can contain item PII, so
  only the resolved key is stored but authors should key off a non-sensitive id.
- node_contracts: terminal-status note now covers 'unknown'/all non-success
  statuses releasing tentative.
- tools.rs config_hint: bound the dedup key hint with truncate_hint (+ test).
- bus.rs release(): drop the extra load_key_set read used only for a log count.
- prompt.md: add language to the fenced workflow example (markdownlint MD040).
- jsonrpc.rs + docs: document the deferred items (per-run tentative scoping,
  group-registration retryability, run-graph snapshot) as follow-ups, not
  reworked in this PR.

@greptile-apps greptile-apps 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.

graycyrus has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@graycyrus

Copy link
Copy Markdown
Contributor Author

Review comments addressed — pushed in 03a9471fd

Fixed:

  • CodeRabbit Major (privacy wording, prompt.md + about_app): softened — a config.key =-expression can resolve to item-derived PII; now states only the resolved key is stored (not full content) and authors should key off a non-sensitive stable id.
  • CodeRabbit (node_contracts.rs): terminal-status note now covers unknown/all non-success statuses releasing tentative.
  • CodeRabbit (tools.rs): dedup config_hint now wrapped with truncate_hint (+ long-key test) so a long key expr can't make node summaries unbounded.
  • Greptile P2 (bus.rs release()): dropped the extra load_key_set read used only for a log count.
  • CodeRabbit (prompt.md): added a language to the fenced workflow example (MD040).

Deferred with follow-up issues (kept out of scope):

CI: the red Rust Core Coverage lane was an unrelated pre-existing medulla env-race (process-global BACKEND_URL leaking across parallel test modules) — fixed separately in #5267; once that merges to main this PR's coverage lane clears.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/openhuman/about_app/catalog_data.rs (1)

1536-1549: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not promise exactly-once processing here.

Because non-success runs leave keys uncommitted, downstream effects can be replayed on retry. This is at-least-once execution with exactly-once commitment after successful completion; update the description to make that limitation explicit.

🤖 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 `@src/openhuman/about_app/catalog_data.rs` around lines 1536 - 1549, The dedup
node description currently overstates its guarantee by promising exactly-once
processing. Update the description string in the catalog entry to describe
at-least-once execution with exactly-once commitment after successful
completion, explicitly noting that downstream effects may replay when
non-successful runs retry; preserve the existing key storage and privacy
details.
🤖 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.

Outside diff comments:
In `@src/openhuman/about_app/catalog_data.rs`:
- Around line 1536-1549: The dedup node description currently overstates its
guarantee by promising exactly-once processing. Update the description string in
the catalog entry to describe at-least-once execution with exactly-once
commitment after successful completion, explicitly noting that downstream
effects may replay when non-successful runs retry; preserve the existing key
storage and privacy details.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 392b7689-6e11-4236-9140-25008ed3bcbd

📥 Commits

Reviewing files that changed from the base of the PR and between f253297 and 03a9471.

📒 Files selected for processing (7)
  • src/core/jsonrpc.rs
  • src/openhuman/about_app/catalog_data.rs
  • src/openhuman/flows/agents/workflow_builder/prompt.md
  • src/openhuman/flows/bus.rs
  • src/openhuman/flows/node_contracts.rs
  • src/openhuman/flows/tools.rs
  • src/openhuman/flows/tools_tests.rs
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/core/jsonrpc.rs
  • src/openhuman/flows/agents/workflow_builder/prompt.md
  • src/openhuman/flows/node_contracts.rs
  • src/openhuman/flows/tools.rs
  • src/openhuman/flows/bus.rs

@greptile-apps greptile-apps 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.

graycyrus has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@graycyrus

Copy link
Copy Markdown
Contributor Author

Re-review after the main merge re-flagged the same items — all are already addressed in 49dc54b50 (fixes in 03a9471fd):

  • release() extra read → dropped (bus.rs, now unconditional kv_delete).
  • dedup config hint → bounded with truncate_hint (tools.rs).
  • terminal-status note → covers unknown/all non-success (node_contracts.rs).
  • privacy wording → softened — a key expr can resolve to item PII; store the resolved key only, key off a non-sensitive stable id (prompt.md:645, about_app).
  • fenced example → language added (MD040).

Deferred (kept the PR scoped, tracked):

The earlier red Rust Core Coverage was the unrelated medulla env-race, fixed in #5267 (merged) and now merged into this branch.

@graycyrus
graycyrus merged commit dd491ca into tinyhumansai:main Jul 29, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature Net-new user-facing capability or product behavior. rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant