fix(trace): recognise runtime boards and same-hole junctions in pin tracing

An ESP32 clock built by the agent stayed dark while QEMU was verifiably
emitting hundreds of GPIO edges per second (437/pin measured on the live
websocket). Reload did not help — this was not the seating race. Two
independent tracing bugs, reproduced from the real project circuit (fixture
included) and each sufficient to kill the display:

Boards added at runtime were invisible
--------------------------------------
isBoardComponent matches static id prefixes ('arduino-uno', ...), which only
covers the default board. Every board added at runtime gets a minted UUID id
— the agent's add_board always does — so traceDetailed treated the board
endpoint as an unknown component and resolved null, and SimulatorCanvas's
direct-wire subscription path skipped it entirely. Every Uno project happened
to work because they reuse the default board whose instance id IS the literal
'arduino-uno'. Both sites now consult the live boards list first, keeping
isBoardComponent as the legacy-id fallback.

Strip walking missed wires stacked on one hole
----------------------------------------------
The breadboard group walk continued the trace from every OTHER wired hole of
the strip, excluding the arrival hole by name. But two wires may legitimately
share one hole — the agent bridges strips straight into the seat hole (8 of
this circuit's 9 bridges land exactly on a resistor's own hole), which is
electrically identical to using a free hole of the strip. The name exclusion
made those junctions dead ends. Exclusion is now by incoming WIRE id, so
same-hole connections resolve; the depth bound already prevents ping-ponging
between two wires of one net.

With both fixes the exact saved circuit resolves every display pin to its
GPIO (A..DP -> 32,33,25,26,27,14,12,13; DIG1..4 -> 15,2,4,5; COM -> GND) and
the live project now shows 12:00 on the real QEMU simulation. traceDetailed
is exported for the regression test, which drives the real store with the
real circuit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-07-20 18:46:23 +02:00
parent 91965e9824
commit 3ac00ae5ff
4 changed files with 805 additions and 8 deletions

View File

@ -0,0 +1,79 @@
// @vitest-environment jsdom
/**
* Reproduction of the reported "ESP32 clock through a breadboard is dead"
* bug, using the exact circuit the agent built (fixture exported from the
* real project). QEMU was proven to emit every GPIO edge (437 events/pin on
* the live site) the break is in the frontend resolution/subscription.
*
* These tests interrogate each stage separately so a failure names the
* broken stage rather than just "display dead".
*/
import { describe, it, expect, beforeAll } from 'vitest';
import { useSimulatorStore } from '../store/useSimulatorStore';
import { traceDetailed } from '../components/DynamicComponent';
import circuit from './fixtures/esp32-clock-circuit.json';
const BOARD = circuit.boardId;
function loadCircuit() {
const s = useSimulatorStore.getState();
s.setComponents(circuit.components.map((c) => ({ ...c, x: 0, y: 0 })) as never);
s.setWires(circuit.wires.map((w) => ({
...w, waypoints: [], color: '#000',
start: { ...w.start, x: 0, y: 0 }, end: { ...w.end, x: 0, y: 0 },
})) as never);
}
describe('ESP32 + breadboard-seated 7segment (real agent circuit)', () => {
beforeAll(() => {
const s = useSimulatorStore.getState();
s.addBoard('esp32' as never, 0, 0, BOARD);
loadCircuit();
});
it('traceDetailed resolves every segment pin to its GPIO through the breadboard', () => {
const state = useSimulatorStore.getState();
const expected: Record<string, number> = {
A: 32, B: 33, C: 25, D: 26, E: 27, F: 14, G: 12, DP: 13,
DIG1: 15, DIG2: 2, DIG3: 4, DIG4: 5,
};
for (const [pin, gpio] of Object.entries(expected)) {
const r = traceDetailed(state as never, '7segment_1', pin, 0);
expect(r.arduinoPin, `7segment.${pin} should reach GPIO ${gpio}`).toBe(gpio);
}
});
it('COM resolves to ground (-1) through the rail', () => {
const state = useSimulatorStore.getState();
const r = traceDetailed(state as never, '7segment_1', 'COM', 0);
expect(r.arduinoPin).toBe(-1);
});
it('the esp32 board exposes a simulator with a live pinManager', () => {
// DynamicComponent's attach effect reads store.simulator; the 7segment's
// attachEvents bails out entirely when simulator.pinManager is missing,
// which strands the display on the breadboard-blind onPinStateChange
// path (single-hop isBoardComponent — never true through a breadboard).
const s = useSimulatorStore.getState();
s.setActiveBoardId(BOARD);
const after = useSimulatorStore.getState();
expect(after.simulator, 'store.simulator for the esp32 board').toBeTruthy();
const pm = (after.simulator as { pinManager?: unknown } | null)?.pinManager;
expect(pm, 'simulator.pinManager (attachEvents bails without it)').toBeTruthy();
});
it('a GPIO edge from the bridge reaches a resolver subscribed via the pinManager', () => {
const s = useSimulatorStore.getState();
const pm = (s.simulator as { pinManager?: {
onPinChange: (pin: number, cb: (pin: number, state: boolean) => void) => () => void;
triggerPinChange: (pin: number, state: boolean, source?: string) => void;
} } | null)?.pinManager;
expect(pm).toBeTruthy();
let seen: boolean | null = null;
const un = pm!.onPinChange(32, (_p, st) => { seen = st; });
// What Esp32Bridge.onPinChange does on a gpio_change frame:
pm!.triggerPinChange(32, true, 'mcu');
un();
expect(seen, 'edge on GPIO 32 must reach subscribers').toBe(true);
});
});

View File

@ -0,0 +1,700 @@
{
"boardId": "56b16fd2-8904-4a5e-b269-5c6071ec285c",
"components": [
{
"id": "breadboard_1",
"metadataId": "breadboard",
"properties": {}
},
{
"id": "7segment_1",
"metadataId": "7segment",
"properties": {
"digits": 4,
"colon": true
}
},
{
"id": "resistor_220_1",
"metadataId": "resistor-220",
"properties": {
"rotation": 90
}
},
{
"id": "resistor_220_2",
"metadataId": "resistor-220",
"properties": {
"rotation": 90
}
},
{
"id": "resistor_220_3",
"metadataId": "resistor-220",
"properties": {
"rotation": 90
}
},
{
"id": "resistor_220_4",
"metadataId": "resistor-220",
"properties": {
"rotation": 90
}
},
{
"id": "resistor_220_5",
"metadataId": "resistor-220",
"properties": {
"rotation": 90
}
},
{
"id": "resistor_220_6",
"metadataId": "resistor-220",
"properties": {
"rotation": 90
}
},
{
"id": "resistor_220_7",
"metadataId": "resistor-220",
"properties": {
"rotation": 90
}
},
{
"id": "resistor_220_8",
"metadataId": "resistor-220",
"properties": {
"rotation": 90
}
}
],
"wires": [
{
"id": "699bda65-cc5a-40",
"start": {
"componentId": "breadboard_1",
"pinName": "21t.d"
},
"end": {
"componentId": "breadboard_1",
"pinName": "35t.b"
},
"bb": false
},
{
"id": "ded18cb8-6804-4e",
"start": {
"componentId": "breadboard_1",
"pinName": "25t.d"
},
"end": {
"componentId": "breadboard_1",
"pinName": "37t.b"
},
"bb": false
},
{
"id": "210ac86a-dd84-42",
"start": {
"componentId": "breadboard_1",
"pinName": "23b.h"
},
"end": {
"componentId": "breadboard_1",
"pinName": "39t.b"
},
"bb": false
},
{
"id": "828e06ff-3b51-47",
"start": {
"componentId": "breadboard_1",
"pinName": "21b.h"
},
"end": {
"componentId": "breadboard_1",
"pinName": "41t.b"
},
"bb": false
},
{
"id": "020ff259-c6ad-47",
"start": {
"componentId": "breadboard_1",
"pinName": "20b.h"
},
"end": {
"componentId": "breadboard_1",
"pinName": "43t.b"
},
"bb": false
},
{
"id": "3d57ce29-1844-49",
"start": {
"componentId": "breadboard_1",
"pinName": "22t.d"
},
"end": {
"componentId": "breadboard_1",
"pinName": "45t.b"
},
"bb": false
},
{
"id": "c24471d2-8acf-42",
"start": {
"componentId": "breadboard_1",
"pinName": "24b.h"
},
"end": {
"componentId": "breadboard_1",
"pinName": "47t.b"
},
"bb": false
},
{
"id": "09dff781-b2a2-4c",
"start": {
"componentId": "breadboard_1",
"pinName": "22b.h"
},
"end": {
"componentId": "breadboard_1",
"pinName": "49t.b"
},
"bb": false
},
{
"id": "6c11a4bf-4271-4b",
"start": {
"componentId": "breadboard_1",
"pinName": "35b.g"
},
"end": {
"componentId": "56b16fd2-8904-4a5e-b269-5c6071ec285c",
"pinName": "32"
},
"bb": false
},
{
"id": "56becf80-4217-49",
"start": {
"componentId": "breadboard_1",
"pinName": "37b.g"
},
"end": {
"componentId": "56b16fd2-8904-4a5e-b269-5c6071ec285c",
"pinName": "33"
},
"bb": false
},
{
"id": "35148f93-0dfb-47",
"start": {
"componentId": "breadboard_1",
"pinName": "39b.g"
},
"end": {
"componentId": "56b16fd2-8904-4a5e-b269-5c6071ec285c",
"pinName": "25"
},
"bb": false
},
{
"id": "8e68b903-6bc1-41",
"start": {
"componentId": "breadboard_1",
"pinName": "41b.g"
},
"end": {
"componentId": "56b16fd2-8904-4a5e-b269-5c6071ec285c",
"pinName": "26"
},
"bb": false
},
{
"id": "74a77153-c2c4-4d",
"start": {
"componentId": "breadboard_1",
"pinName": "43b.g"
},
"end": {
"componentId": "56b16fd2-8904-4a5e-b269-5c6071ec285c",
"pinName": "27"
},
"bb": false
},
{
"id": "2a0eb2fd-d583-4d",
"start": {
"componentId": "breadboard_1",
"pinName": "45b.g"
},
"end": {
"componentId": "56b16fd2-8904-4a5e-b269-5c6071ec285c",
"pinName": "14"
},
"bb": false
},
{
"id": "b526bda1-01aa-47",
"start": {
"componentId": "breadboard_1",
"pinName": "47b.g"
},
"end": {
"componentId": "56b16fd2-8904-4a5e-b269-5c6071ec285c",
"pinName": "12"
},
"bb": false
},
{
"id": "966f0a6e-bf67-4e",
"start": {
"componentId": "breadboard_1",
"pinName": "49b.g"
},
"end": {
"componentId": "56b16fd2-8904-4a5e-b269-5c6071ec285c",
"pinName": "13"
},
"bb": false
},
{
"id": "96d6e3a9-6ca0-44",
"start": {
"componentId": "breadboard_1",
"pinName": "20t.d"
},
"end": {
"componentId": "56b16fd2-8904-4a5e-b269-5c6071ec285c",
"pinName": "15"
},
"bb": false
},
{
"id": "7ebaeaa1-38ca-47",
"start": {
"componentId": "breadboard_1",
"pinName": "23t.d"
},
"end": {
"componentId": "56b16fd2-8904-4a5e-b269-5c6071ec285c",
"pinName": "2"
},
"bb": false
},
{
"id": "2fe1ff73-a9bd-49",
"start": {
"componentId": "breadboard_1",
"pinName": "24t.d"
},
"end": {
"componentId": "56b16fd2-8904-4a5e-b269-5c6071ec285c",
"pinName": "4"
},
"bb": false
},
{
"id": "88cad420-25f5-4a",
"start": {
"componentId": "breadboard_1",
"pinName": "25b.h"
},
"end": {
"componentId": "56b16fd2-8904-4a5e-b269-5c6071ec285c",
"pinName": "5"
},
"bb": false
},
{
"id": "26f601ef-fb7d-4d",
"start": {
"componentId": "breadboard_1",
"pinName": "26b.h"
},
"end": {
"componentId": "breadboard_1",
"pinName": "bn.1"
},
"bb": false
},
{
"id": "a86f8a48-6cca-4d",
"start": {
"componentId": "breadboard_1",
"pinName": "bn.1"
},
"end": {
"componentId": "56b16fd2-8904-4a5e-b269-5c6071ec285c",
"pinName": "GND"
},
"bb": false
},
{
"id": "bbwire-7segment_",
"start": {
"componentId": "7segment_1",
"pinName": "A"
},
"end": {
"componentId": "breadboard_1",
"pinName": "21t.a"
},
"bb": true
},
{
"id": "bbwire-7segment_",
"start": {
"componentId": "7segment_1",
"pinName": "B"
},
"end": {
"componentId": "breadboard_1",
"pinName": "25t.a"
},
"bb": true
},
{
"id": "bbwire-7segment_",
"start": {
"componentId": "7segment_1",
"pinName": "C"
},
"end": {
"componentId": "breadboard_1",
"pinName": "23b.f"
},
"bb": true
},
{
"id": "bbwire-7segment_",
"start": {
"componentId": "7segment_1",
"pinName": "D"
},
"end": {
"componentId": "breadboard_1",
"pinName": "21b.f"
},
"bb": true
},
{
"id": "bbwire-7segment_",
"start": {
"componentId": "7segment_1",
"pinName": "E"
},
"end": {
"componentId": "breadboard_1",
"pinName": "20b.f"
},
"bb": true
},
{
"id": "bbwire-7segment_",
"start": {
"componentId": "7segment_1",
"pinName": "F"
},
"end": {
"componentId": "breadboard_1",
"pinName": "22t.a"
},
"bb": true
},
{
"id": "bbwire-7segment_",
"start": {
"componentId": "7segment_1",
"pinName": "G"
},
"end": {
"componentId": "breadboard_1",
"pinName": "24b.f"
},
"bb": true
},
{
"id": "bbwire-7segment_",
"start": {
"componentId": "7segment_1",
"pinName": "DP"
},
"end": {
"componentId": "breadboard_1",
"pinName": "22b.f"
},
"bb": true
},
{
"id": "bbwire-7segment_",
"start": {
"componentId": "7segment_1",
"pinName": "DIG1"
},
"end": {
"componentId": "breadboard_1",
"pinName": "20t.a"
},
"bb": true
},
{
"id": "bbwire-7segment_",
"start": {
"componentId": "7segment_1",
"pinName": "DIG2"
},
"end": {
"componentId": "breadboard_1",
"pinName": "23t.a"
},
"bb": true
},
{
"id": "bbwire-7segment_",
"start": {
"componentId": "7segment_1",
"pinName": "DIG3"
},
"end": {
"componentId": "breadboard_1",
"pinName": "24t.a"
},
"bb": true
},
{
"id": "bbwire-7segment_",
"start": {
"componentId": "7segment_1",
"pinName": "DIG4"
},
"end": {
"componentId": "breadboard_1",
"pinName": "25b.f"
},
"bb": true
},
{
"id": "bbwire-7segment_",
"start": {
"componentId": "7segment_1",
"pinName": "COM"
},
"end": {
"componentId": "breadboard_1",
"pinName": "26b.f"
},
"bb": true
},
{
"id": "bbwire-7segment_",
"start": {
"componentId": "7segment_1",
"pinName": "CLN"
},
"end": {
"componentId": "breadboard_1",
"pinName": "26t.a"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_1",
"pinName": "1"
},
"end": {
"componentId": "breadboard_1",
"pinName": "35t.b"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_1",
"pinName": "2"
},
"end": {
"componentId": "breadboard_1",
"pinName": "35b.f"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_2",
"pinName": "1"
},
"end": {
"componentId": "breadboard_1",
"pinName": "37t.b"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_2",
"pinName": "2"
},
"end": {
"componentId": "breadboard_1",
"pinName": "37b.f"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_3",
"pinName": "1"
},
"end": {
"componentId": "breadboard_1",
"pinName": "39t.b"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_3",
"pinName": "2"
},
"end": {
"componentId": "breadboard_1",
"pinName": "39b.f"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_4",
"pinName": "1"
},
"end": {
"componentId": "breadboard_1",
"pinName": "41t.b"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_4",
"pinName": "2"
},
"end": {
"componentId": "breadboard_1",
"pinName": "41b.f"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_5",
"pinName": "1"
},
"end": {
"componentId": "breadboard_1",
"pinName": "43t.b"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_5",
"pinName": "2"
},
"end": {
"componentId": "breadboard_1",
"pinName": "43b.f"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_6",
"pinName": "1"
},
"end": {
"componentId": "breadboard_1",
"pinName": "45t.b"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_6",
"pinName": "2"
},
"end": {
"componentId": "breadboard_1",
"pinName": "45b.f"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_7",
"pinName": "1"
},
"end": {
"componentId": "breadboard_1",
"pinName": "47t.b"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_7",
"pinName": "2"
},
"end": {
"componentId": "breadboard_1",
"pinName": "47b.f"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_8",
"pinName": "1"
},
"end": {
"componentId": "breadboard_1",
"pinName": "49t.b"
},
"bb": true
},
{
"id": "bbwire-resistor_",
"start": {
"componentId": "resistor_220_8",
"pinName": "2"
},
"end": {
"componentId": "breadboard_1",
"pinName": "49b.f"
},
"bb": true
}
]
}

View File

@ -122,7 +122,7 @@ type TraceState = ReturnType<typeof useSimulatorStore.getState>;
// Lifted to module scope (was inside getArduinoPin) so that getPinResolver
// can call it too — the previous nested-scope version caused a runtime
// ReferenceError "traceDetailed is not defined" on the simulator page.
function traceDetailed(
export function traceDetailed(
state: TraceState,
fromId: string,
fromPin: string,
@ -146,10 +146,16 @@ function traceDetailed(
w.start.componentId === fromId && w.start.pinName === fromPin ? w.start : w.end;
const otherEp = selfEp === w.start ? w.end : w.start;
if (isBoardComponent(otherEp.componentId)) {
const boardKind =
state.boards.find((b) => b.id === otherEp.componentId)?.boardKind ??
otherEp.componentId;
// A board endpoint is recognised by the LIVE boards list first.
// `isBoardComponent` matches static id prefixes ('arduino-uno', …), which
// only covers the default board — every board added at runtime (the agent
// mints UUID ids) failed the check, so tracing treated it as an unknown
// component and returned null. Symptom: an ESP32 clock whose QEMU was
// emitting hundreds of GPIO edges/second at a display that stayed dark,
// because no resolver ever attached.
const boardEp = state.boards.find((b) => b.id === otherEp.componentId);
if (boardEp || isBoardComponent(otherEp.componentId)) {
const boardKind = boardEp?.boardKind ?? otherEp.componentId;
const pin = boardPinToNumber(boardKind, otherEp.pinName);
if (pin !== null) return { arduinoPin: pin, crossedActiveDevice: activeSeen };
} else {
@ -176,14 +182,22 @@ function traceDetailed(
// Breadboards join N holes per internal group (5-hole strip / power
// rail), which the 2-terminal PASSIVE_PIN_PAIRS map can't express.
// Continue the trace from every OTHER wired hole in the same group.
//
// Exclusion is by INCOMING WIRE, not by hole name: two wires may
// legitimately share one hole (a seated pin plus a jumper landing in
// that same hole — the agent bridges strips straight into the seat
// hole). Excluding the arrival hole made those stacked connections
// invisible: an ESP32 clock with QEMU firing hundreds of GPIO edges
// per second sat dark because every segment's bridge landed on its
// resistor's own seat hole and the trace dead-ended there.
const bbGroup = comp && breadboardGroupKey(comp.metadataId, otherEp.pinName);
if (bbGroup && comp) {
const groupPins = new Set<string>();
for (const gw of state.wires) {
if (gw.id === w.id) continue; // never bounce back on the same wire
for (const ep of [gw.start, gw.end]) {
if (
ep.componentId === comp.id &&
ep.pinName !== otherEp.pinName &&
breadboardGroupKey(comp.metadataId, ep.pinName) === bbGroup
) {
groupPins.add(ep.pinName);

View File

@ -1153,11 +1153,15 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
const selfEndpoint = isStartSelf ? wire.start : wire.end;
const otherEndpoint = isStartSelf ? wire.end : wire.start;
if (isBoardComponent(otherEndpoint.componentId)) {
// Recognise board endpoints from the LIVE boards list first —
// isBoardComponent only matches static id prefixes, so every
// runtime-added board (agent-minted UUID ids) failed it and its
// directly-wired components were never subscribed.
const boardInstance = boards.find((b) => b.id === otherEndpoint.componentId);
if (boardInstance || isBoardComponent(otherEndpoint.componentId)) {
// Use the board's actual boardKind (not just its instance ID) so that
// a board whose ID is 'arduino-uno' but whose kind is 'esp32' gets the
// correct GPIO mapping ('GPIO4' → 4, not null).
const boardInstance = boards.find((b) => b.id === otherEndpoint.componentId);
const lookupKey = boardInstance ? boardInstance.boardKind : otherEndpoint.componentId;
const pin = boardPinToNumber(lookupKey, otherEndpoint.pinName);
if (pin !== null && pin >= 0) {