feat(flows): NF-6 forge-mutation divergence parity tooling #376
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
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/claude-hooks!376
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/flows-divergence-forge-mutations"
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
Phase 2 of the NF-6 cutover. Extends the divergence tooling so the operator can validate forge-side parity for the 7 NF-6 flows that follow
issue.assigned— every one of which callsaddLabels/createComment/requestReview/mergePullRequest/dismissReview. Without this, the soak protocol can only diff dispatch intents (currently shipped asGET /flows/divergences) and has no signal on the forge-mutation side.What this PR does
legacy_forge_callsSQLite table —(method, repo, target_number, args_json, fired_at). Migration is the sameCREATE TABLE IF NOT EXISTSshape as every other flows table.instrumentForLegacyParityProxy inadapter-factory.ts— wraps everycreateForgeAdapterForReporeturn so mutating methods append a row tolegacy_forge_calls. 12 mutating methods covered (matches theforge.*mutating SPECS inforge-nodes.ts). Read-only methods are pass-through (Reflect.getreturns the original fn unchanged so the JIT can inline).GET /flows/divergence/summary?since=<iso>— per-method bucket of(flow_intents, legacy_calls, matched_pairs, flow_only, legacy_only)matched on(method, repo, target_number). Plus the existingagent.dispatchheuristic, plus a roll-up totals object the operator polls daily during soak.Operator usage during soak
Returns:
For each new NF-6 flow, wait until both
flow_onlyandlegacy_onlydrop to zero across the soak window before adding the matching trigger tonode_flows.suppress_legacy.Failure semantics
Instrumentation row insertion is wrapped in try/catch (
insertLegacyForgeCallswallows + logs). A divergence-table hiccup never breaks a real legacy call.Tests
flows-divergence-summary.test.ts— all passWhat's next
issue.assigned🤖 Generated with Claude Code
The existing /flows/divergences endpoint correlates `agent.dispatch` intents against `task_history` rows but doesn't see any other forge mutation. Every NF-6 flow except `issue.assigned` calls `addLabels` / `createComment` / `requestReview` / `mergePullRequest` / `dismissReview` etc. — without an instrumentation hook on the legacy adapter, the operator has no way to validate parity before flipping `suppress_legacy` on those triggers. This PR adds: - New `legacy_forge_calls` SQLite table — `(method, repo, target_number, args_json, fired_at)`. Migration is the same `CREATE TABLE IF NOT EXISTS` shape as every other flows table; idempotent. - `instrumentForLegacyParity` Proxy in `adapter-factory.ts` — wraps every `createForgeAdapterForRepo` return so mutating methods append a row to `legacy_forge_calls`. 12 mutating methods covered (matches the `forge.*` mutating SPECS in `forge-nodes.ts`). Read-only methods are pass-through (Reflect.get returns the original fn). - `GET /flows/divergence/summary?since=<iso>` — per-method bucket of `(flow_intents, legacy_calls, matched_pairs, flow_only, legacy_only)` matched on `(method, repo, target_number)`. Plus the existing `agent.dispatch` heuristic, plus a roll-up totals object the operator polls daily during soak. - 8 tests cover empty inputs, matched pairs, flow-only intents, legacy-only calls, repo / target-number mismatch, non-forge intents ignored, and the duplicate-intent fan-in case. Operator usage during soak: - Watch `GET /flows/divergence/summary?since=<24h>` daily. - For each new flow, wait until both `flow_only` and `legacy_only` drop to zero across the soak window before adding the matching trigger to `node_flows.suppress_legacy`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>Round 3 review feedback: - **Circular import** between `adapter-factory` (value-imported `FORGE_NODE_TYPES` from `forge-nodes`) and `forge-nodes` (already value-imports `createForgeAdapterForRepo` from `adapter-factory`). Module evaluation order matters under HMR / test ordering — the IIFE-style assertion would have thrown a misleading "no matching forge.* node type" or `TypeError` if forge-nodes was the first entry point. Replaced the import with a hard-coded `KNOWN_FORGE_MUTATING_TYPES` Set inline; the assertion still catches `getURL → get_u_r_l` and copy-paste typos at boot, and the comment explicitly documents that drift between the two lists is the whole risk this assertion exists to catch (so independent declarations are correct, not duplication). - **`dispatchFlowIntents` lost the `nowMs` filter** that `dispatch.matched_pairs` and `dispatch.flow_only_intents` still apply. With clock-skewed / future-dated dispatch intents, `summary.dispatch.flow_intents` could exceed `matched + flow_only`. Now reuses `parseIntentRows` with the same `fired_at_ms <= nowMs` filter so the three reported numbers reconcile, and avoids drift if the intent envelope shape changes. - Dropped the redundant `(err) => { throw err; }` rejection arm on the Proxy's `result.then(...)`. An absent rejection handler propagates rejection identically — same effect, less noise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>