feat(flows): dry-run mode + divergence detection (NF-5) #358
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/claude-hooks!358
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/326-nf5-dry-run-divergence"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Parallel-dispatch feature flag gates whether the Node-Flows executor fires alongside the legacy handlers. In dry-run every mutating
forge.*/agent.*node short-circuits to a stub output and records its intended call on the newflow_node_runs.intentcolumn; a heuristic correlates those intents againsttask_historyso the operator can eyeball divergences before NF-6 flips the live cutover.node_flows: { mode, summary_interval_ms, divergence_window_ms }block inconfig/agents.json.modedefaults to"off"; typos fail the loader ("dryrun","LIVE","Off"— all rejected).intent TEXTtoflow_node_runs(same PRAGMA-table_info pattern used foragents.plugins).mutationMode+ctx.recordIntent(method, args)through theNodeHandlerCtx;NodeRunRecord.intentsurfaces the recorded payload so the persister can write the column unchanged.dryRunStub; the handler wrapper reads the mode from the context and either short-circuits (recording intent, returning the stub) or falls through to the real adapter. Read-only nodes always query the real forge.GET /flows/divergences?since=&window_ms=serves a matched / flow-only / legacy-only bucket envelope withheuristic_limitsinline. Route registered before/flows/:idso Hono's radix matching doesn't let the wildcard swallow the literal.background/divergence-summary.tsemits a dailyflow.divergence_summarySSE envelope (interval configurable, default 24 h). Timer is only started whenmode !== "off".Design notes
Mutation wiring. A
wrapMutationNode-style refactor would touch every forge spec individually; instead the spec table gained one new field (dryRunStub) andbuildDescriptordoes the short-circuit declaratively. Adding a new mutating forge node is one spec + one stub line, same shape as today.Intent column in live mode. Live rows carry NULL in
intent— the divergence endpoint's heuristic only correlates dry-run intents againsttask_history. Recording intent in live mode was considered but dropped (would invert the "NULL means live" invariant the endpoint relies on; easier to trust the existinginput/outputcolumns in live mode).Mutating nodes wired for dry-run. Every NF-3 mutating spec gets a stub:
forge.add_labels,forge.remove_label,forge.create_issue,forge.patch_issue,forge.update_assignees,forge.create_comment,forge.request_review,forge.merge_pull_request,forge.add_blocker,forge.create_label,forge.write_fileagent.dispatch(returnsdry-run-<timestamp>-<counter>taskId),agent.cancel,agent.raise_capRead-only nodes (
forge.get_issue,forge.list_issues,forge.get_pull_request,forge.list_pull_requests,forge.list_comments,forge.list_reviews,forge.list_workflow_runs,forge.get_aggregate_status,forge.get_blockers,forge.read_file) are untouched — they hit the real forge in both modes so downstreamrouter.switchkeeps walking.Heuristic caveats (documented in the endpoint response)
The divergence endpoint is intentionally imperfect — documented inline in
heuristic_limits:agent.dispatchintents correlate againsttask_history(the hottest legacy mutation). Non-dispatch mutations (addLabels,createComment,requestReview, …) always land inflow_only_intentswithout a legacy counterpart — that's expected, not a divergence signal.fired_at; configurable vianode_flows.divergence_window_msor?window_ms=. A legacy dispatch that lands outside the window reads as "legacy-only" on one side and "flow-only" on the other.task_historyrow is matched at most once. When multiple intents target the same(repo, issue, agent_type), the closest-in-time pair wins and the rest bucket asflow_only_intents.webhook-handlers.ts,webhook-ci.ts,deps.ts,slash-commands.ts) were NOT touched — the heuristic readstask_historyinstead. Non-dispatch legacy side effects (labels / comments / reviews) are invisible to the correlator; that's a known gap that NF-6's live cutover validates empirically.Scope respected
Untouched:
webhook-handlers.ts,webhook-ci.ts,deps.ts,slash-commands.ts,agent-runner.ts,adapter-factory,forgejo-port, every UI file. The feature is purely additive — legacy dispatch stays the authoritative source of truth.Rollout. The feature flag is not flipped on
charles/claude-hooksin this PR — that's an operator action after review.Test plan
just qa(typecheck clean, biome clean) — web-workspace test failures are preexisting (verified by stash-compare against base).shared/config/flow-mode.test.ts(26)domain/flows/dry-run-executor.test.ts(27)http/flows-divergence.test.ts(26)background/divergence-summary.test.ts(11)main, confirmed by stash-compare).mode: "off",summary_interval_ms: 86_400_000,divergence_window_ms: 60_000.node_flows.mode = "dry-run"inconfig/agents.json, drive a triggering event, confirmflow_node_runs.intentpopulates andGET /flows/divergencesreturns the expected buckets.Closes #326
🤖 Generated with Claude Code
Parallel-dispatch feature flag gates whether the Node-Flows executor fires alongside the legacy handlers. In dry-run every mutating `forge.*` / `agent.*` node short-circuits to a stub output and records its intended call on the new `flow_node_runs.intent` column; a background heuristic correlates those intents against `task_history` to expose divergences before NF-6 flips the live cutover. - `node_flows: { mode, summary_interval_ms, divergence_window_ms }` block in `config/agents.json`. `mode` defaults to `"off"`; typos fail the loader. - `flow_node_runs.intent` column (idempotent `ALTER TABLE` migration). - Executor plumbs `mutationMode` + `ctx.recordIntent(method, args)` through the NodeHandlerCtx; NodeRunRecord surfaces the intent. - Every mutating forge / agent node gets a declarative `dryRunStub` and an up-front short-circuit in the handler wrapper; read-only nodes keep hitting the real adapter. - `GET /flows/divergences?since=&window_ms=` serves a matched / flow-only / legacy-only bucket envelope. Heuristic is the cheap `(repo, issue_number, agent_type)` ±window correlation — limits documented in the response's `heuristic_limits` field. - Daily `flow.divergence_summary` SSE broadcast via a background timer (only started when `mode !== "off"`). 30-40 tests target: 90 new tests across flow-mode, dry-run-executor, flows-divergence, and divergence-summary. Closes #326 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>