← Back to syllabus
Eval Discipline · Week 2 · Day 2/7
DAY 9 / 210

LangGraph State Channels Deep Dive

Understanding channels and reducers is required before refactoring the pain discovery gate. This day grounds the mergeIntoState pattern from PR #81 in LangGraph primitives so the refactor is reliable rather than ad-hoc.

45 min target📝 2 quiz Qs🔗 2 code anchors

Resources

Codebase anchors

The Tribunal code that demonstrates today's concept. Click the line to open in GitHub or VS Code.

lib/graphs/pain-discovery-nodes.ts:L136mergeIntoState

This mergeIntoState function is the existing manual state handling pattern that LangGraph channels and reducers will replace on this day.

116 * returns an object via spread (`{ ...state, x: 1 }`), every state
117 * field is a candidate for the reducer. The reducer
118 * `(left, right) => right ?? left` works correctly for declared
119 * channels — but the spread can carry over fields whose values came
120 * from a stale state-snapshot reference, depending on how LangGraph's
121 * internals choose to deliver state to the next node.
122 *
123 * This shape was the suspected root cause of the 2026-05-30 Cuba/NA
124 * leak: webSearchAttempted was set to true by webSearchFallbackNode,
125 * but the second-pass selectSignalNode read it as undefined — even
126 * though declaring it as a channel should have preserved it.
127 *
128 * Explicit field-by-field returns give the reducer a clear write per
129 * channel and don't rely on JavaScript spread semantics being
130 * compatible with LangGraph's snapshot model.
131 *
132 * Every node now goes through this helper. If a future field is
133 * added to PainDiscoveryState, this function fails the TypeScript
134 * exhaustiveness check until the field is wired here too.
135 */
136function mergeIntoState(
137 state: PainDiscoveryState,
138 deltas: Partial<PainDiscoveryState>,
139): PainDiscoveryState {
140 return {
141 factors: deltas.factors ?? state.factors,
142 traceId: deltas.traceId ?? state.traceId,
143 painSignals: deltas.painSignals ?? state.painSignals,
144 selectedSignal:
145 deltas.selectedSignal !== undefined ? deltas.selectedSignal : state.selectedSignal,
146 searchAttempts: deltas.searchAttempts ?? state.searchAttempts,
147 searchStrategies: deltas.searchStrategies ?? state.searchStrategies,
148 painConfidence: deltas.painConfidence ?? state.painConfidence,
149 usedSynthetic: deltas.usedSynthetic ?? state.usedSynthetic,
150 syntheticRate: deltas.syntheticRate ?? state.syntheticRate,
151 needsCountryRelevantFallback:
152 deltas.needsCountryRelevantFallback !== undefined
153 ? deltas.needsCountryRelevantFallback
154 : state.needsCountryRelevantFallback,
155 webSearchAttempted:
156 deltas.webSearchAttempted !== undefined
lib/graphs/pain-discovery-nodes.ts:L237mergeIntoState

This call site shows current delta merging that must be expressed via typed channels after the deep dive.

217 searchAfricanSites(factors as any),
218 ]);
219 return [...registryRss, ...hnSignals, ...ihSignals, ...phSignals, ...africanSignals];
220}
221
222// ---------------------------------------------------------------------------
223// Nodes
224// ---------------------------------------------------------------------------
225
226/** Primary search — exact factors, all sources in parallel. */
227export async function searchPrimaryNode(
228 state: PainDiscoveryState,
229): Promise<PainDiscoveryState> {
230 console.log('[PAIN_DISCOVERY] Primary search with exact factors:', state.factors);
231
232 try {
233 const allSignals = await fanOutAllSources(state.factors);
234 const rankedSignals = rankPainSignals(allSignals, state.factors as any);
235 console.log(`[PAIN_DISCOVERY] Primary search found ${rankedSignals.length} signals`);
236
237 return mergeIntoState(state, {
238 painSignals: rankedSignals,
239 searchAttempts: 1,
240 searchStrategies: ['primary'],
241 });
242 } catch (error) {
243 console.error('[PAIN_DISCOVERY] Primary search error:', error);
244 return mergeIntoState(state, {
245 painSignals: [],
246 searchAttempts: 1,
247 searchStrategies: ['primary'],
248 });
249 }
250}
251
252/**
253 * Adjacent search — same sources, broadened factors.
254 * Attempt 1: broaden region to regional cluster.
255 * Attempt 2: drop demographic constraint.
256 * Attempt 3: both.
257 */

Deliverable

Journal entry with channel schema and reducer plan for the PR #81 refactor

Quiz · 2 questions

1. What happens when two nodes write to the same channel without a reducer?

2. Write the reducer signature needed to merge painSignals arrays across nodes.

Journal