← All Pull Requests

0.2.47: inner pours in Fusion, follow long schematic runs #62

Merged Released in 0.2.47 on 3 Nov 2026 opened by Colby Knox
Merges aiflow-lane-0.2.47 → main

Follow-ups to Fusion bridge 1.13.64.

  • land pours lands a pour on any copper layer, by name. The inner_pour_unsupported refusal is gone.
  • fusion schematic polls a long list (stillRunning, runId) to its end and checks the final answer. It never sends a list twice, and a run it can't follow stops and names the runId.
  • Docs: a new "Inner copper layers" section, including how to repair a board an older bridge converted.

Base 472faee2. 541 Rust tests and both Python suites pass. Preflight flags one removed test, replaced by tests for the new behaviour.

Live on bridge 1.13.64: a 4-layer route with two vias, pours on In1.Cu and F.Cu beside an In2.Cu plane, and a 762-command list of 107 parts that answered stillRunning and passed the pin-by-pin check. The long list used a scratch copy of the tool that merges the groups; that line is not in this PR. A real part with more than about 190 pins was not tested.

🤖 Generated with Claude Code

Diff Skip to comments

Cargo.lock+10−10
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-aiflow"-version = "0.2.46"+version = "0.2.47" dependencies = [  "aiflow-analyze",  "aiflow-board",@@ -25,7 +25,7 @@ dependencies = [  [[package]] name = "aiflow-analyze"-version = "0.2.46"+version = "0.2.47" dependencies = [  "aiflow-board",  "serde",@@ -35,7 +35,7 @@ dependencies = [  [[package]] name = "aiflow-board"-version = "0.2.46"+version = "0.2.47" dependencies = [  "roxmltree",  "serde",@@ -44,7 +44,7 @@ dependencies = [  [[package]] name = "aiflow-bridge"-version = "0.2.46"+version = "0.2.47" dependencies = [  "aiflow-board",  "serde",@@ -53,7 +53,7 @@ dependencies = [  [[package]] name = "aiflow-copper"-version = "0.2.46"+version = "0.2.47" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -63,7 +63,7 @@ dependencies = [  [[package]] name = "aiflow-grid"-version = "0.2.46"+version = "0.2.47" dependencies = [  "aiflow-board",  "serde",@@ -72,7 +72,7 @@ dependencies = [  [[package]] name = "aiflow-place"-version = "0.2.46"+version = "0.2.47" dependencies = [  "aiflow-board",  "serde",@@ -81,7 +81,7 @@ dependencies = [  [[package]] name = "aiflow-pours"-version = "0.2.46"+version = "0.2.47" dependencies = [  "aiflow-board",  "aiflow-copper",@@ -91,7 +91,7 @@ dependencies = [  [[package]] name = "aiflow-router"-version = "0.2.46"+version = "0.2.47" dependencies = [  "aiflow-board",  "aiflow-grid",@@ -101,7 +101,7 @@ dependencies = [  [[package]] name = "aiflow-run"-version = "0.2.46"+version = "0.2.47" dependencies = [  "serde",  "serde_json",
Cargo.toml+1−1
@@ -14,7 +14,7 @@ members = [ ]  [workspace.package]-version = "0.2.46"+version = "0.2.47" edition = "2021" license = "MIT" repository = "https://wiki.adom.inc/adom/adom-aiflow"
crates/aiflow-bridge/src/fusion.rs+16−32
@@ -146,7 +146,7 @@ pub fn translate(verb: &str, args: &Value, doc: &str, call: &dyn Fn(&str, &Value         }         "kicad_apply_zone_plan" => {             let (id, rev) = match guarded(args["expectedRevision"].as_str()) { Ok(x) => x, Err(e) => return e };-            let mut pours = Vec::new(); let mut vias = Vec::new(); let mut skipped = Vec::new(); let mut inner: Vec<(Value, Value, String)> = Vec::new();+            let mut pours = Vec::new(); let mut vias = Vec::new(); let mut skipped = Vec::new();             for z in args["zones"].as_array().cloned().unwrap_or_default() {                 if z["type"].as_str() == Some("keepout") { skipped.push(json!({"name": z["name"], "why": "keepout zones are not landed in Fusion; the pours are drawn clear of them"})); continue; }                 if z["kind"].as_str() == Some("via") || z.get("via").is_some() { vias.push(z); continue; }@@ -157,29 +157,9 @@ pub fn translate(verb: &str, args: &Value, doc: &str, call: &dyn Fn(&str, &Value                     let Some(ln) = layer_arg(&l) else {                         return json!({"success": false, "errorCode": "unknown_layer", "error": format!("{} wants a pour on {l}, which is not a copper layer: use F.Cu, In<n>.Cu or B.Cu. Nothing was landed.", z["name"].as_str().unwrap_or("A zone"))});                     };-                    // fusion_apply_pours draws on top and bottom only: an inner pour is checked below-                    if !matches!(ln.as_i64(), Some(1) | Some(16)) { inner.push((z["name"].clone(), z["net"].clone(), l.as_str().map(str::to_string).unwrap_or_else(|| l.to_string()))); continue; }                     pours.push(json!({"net": z["net"], "layer": ln, "points": pts, "isolate": z["clearance"].as_f64().unwrap_or(0.25), "rank": (6 - prio).clamp(1, 6), "width": z["minThickness"].as_f64().unwrap_or(0.25), "thermals": z["connection"].as_str() != Some("solid")}));                 }             }-            if !inner.is_empty() {-                // never dropped in silence: fine when that pour is already on the board, refused when it is not-                let live = call("fusion_pour_state", &json!({"expectDocument": doc}));-                if live["success"].as_bool() != Some(true) { return live; }-                let names = layer_names(&live);-                let on_board = |net: &Value, layer: &str| -> bool {-                    let in_zones = live["zones"].as_array().into_iter().flatten().any(|z| z["net"] == *net && z["layer"].as_str() == Some(layer));-                    in_zones || live["pours"].as_array().into_iter().flatten().any(|p| p["net"] == *net && layer_label(p["layer"].as_i64().unwrap_or(1), &names) == layer)-                };-                let mut missing = Vec::new();-                for (name, net, layer) in &inner {-                    if on_board(net, layer) { skipped.push(json!({"name": name, "why": format!("already on {layer} in Fusion; fusion_apply_pours draws on top and bottom only, so it is kept as drawn")})); }-                    else { missing.push(format!("{} on {layer}", net.as_str().unwrap_or("?"))); }-                }-                if !missing.is_empty() {-                    return json!({"success": false, "errorCode": "inner_pour_unsupported", "error": format!("the Fusion bridge cannot draw a pour on an inner layer yet: {}. Nothing was landed. Draw it in Fusion and land the plan again (a pour already there is kept), or take it out of the plan.", missing.join(", "))});-                }-            }             if pours.is_empty() { return json!({"success": true, "mutated": false, "skipped": skipped, "note": "nothing to pour in Fusion"}); }             let mut r = call("fusion_apply_pours", &json!({"expectDocument": doc, "boardId": id, "expectedRevision": rev, "pours": pours}));             r["skipped"] = json!(skipped);@@ -391,24 +371,28 @@ mod tests {         assert_eq!(sent.borrow().len(), n, "a refused route sends nothing, not even a default-layer draw");     }     #[test]-    fn an_inner_pour_is_kept_when_drawn_and_refused_when_not_never_dropped() {+    fn an_inner_pour_lands_by_name_beside_the_outer_ones() {         let sent = std::cell::RefCell::new(Vec::new());         let call = |v: &str, a: &Value| four_layer(&sent, v, a);         let square = json!([[0.0, 0.0], [5.0, 0.0], [5.0, 5.0], [0.0, 5.0]]);-        // not on the board: refused in words, nothing landed, not even the outer pour beside it         let r = translate("kicad_apply_zone_plan", &json!({"zones": [{"name": "top", "net": "GND", "layers": ["F.Cu"], "polygon": square}, {"name": "plane", "net": "PGND", "layers": ["In1.Cu"], "polygon": square}]}), "Doc-brd", &call);-        assert_eq!(r["success"], false); assert_eq!(r["errorCode"], "inner_pour_unsupported");-        assert!(r["error"].as_str().unwrap().contains("PGND on In1.Cu"), "{r}");-        assert!(!sent.borrow().iter().any(|(v, _)| v == "fusion_apply_pours"));-        // already drawn in Fusion: kept and said so, the outer pour still lands-        let r = translate("kicad_apply_zone_plan", &json!({"zones": [{"name": "top", "net": "GND", "layers": ["F.Cu"], "polygon": square}, {"name": "plane", "net": "AGND", "layers": ["In2.Cu"], "polygon": square}]}), "Doc-brd", &call);-        assert_eq!(r["success"], true);-        assert!(r["skipped"][0]["why"].as_str().unwrap().contains("In2.Cu"), "{r}");+        assert_eq!(r["success"], true, "{r}");         let (v, a) = sent.borrow().last().cloned().unwrap();-        assert_eq!(v, "fusion_apply_pours"); assert_eq!(a["pours"].as_array().unwrap().len(), 1); assert_eq!(a["pours"][0]["layer"], 1);-        // not copper: refused+        assert_eq!(v, "fusion_apply_pours");+        assert_eq!(a["pours"][0]["layer"], 1); assert_eq!(a["pours"][1]["layer"], "In1.Cu"); assert_eq!(a["pours"][1]["net"], "PGND");+        // not copper: refused, and nothing landed+        let n = sent.borrow().iter().filter(|(v, _)| v == "fusion_apply_pours").count();         let r = translate("kicad_apply_zone_plan", &json!({"zones": [{"name": "x", "net": "GND", "layers": ["F.SilkS"], "polygon": square}]}), "Doc-brd", &call);         assert_eq!(r["errorCode"], "unknown_layer");+        assert_eq!(sent.borrow().iter().filter(|(v, _)| v == "fusion_apply_pours").count(), n);+    }+    #[test]+    fn a_pour_the_bridge_refuses_comes_back_as_the_bridges_answer() {+        let call = |v: &str, a: &Value| -> Value {+            if v == "fusion_apply_pours" { json!({"success": false, "errorCode": "readback_mismatch", "missing": ["PGND"]}) } else { four_layer(&std::cell::RefCell::new(Vec::new()), v, a) }+        };+        let r = translate("kicad_apply_zone_plan", &json!({"zones": [{"name": "plane", "net": "PGND", "layers": ["In1.Cu"], "polygon": [[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]]}]}), "Doc-brd", &call);+        assert_eq!(r["success"], false); assert_eq!(r["errorCode"], "readback_mismatch"); assert_eq!(r["missing"][0], "PGND");     }     #[test]     fn standard_board_model_pads_keep_their_ref_pad_name() {
docs/fusion-lane.md+10−1
@@ -94,6 +94,8 @@ Fusion 2706 raises its internal error `PlacePartWorker.cpp, Line 23. Condition:  `fusion schematic` keeps every bridge reply whole in `<run>/fusion/schematic/`: the import's `runId`, start reply and final status (`import.json`), and each `fusion_schematic_run` group (`run-NN-<group>.json`). An error names its file. `add_failed` and an `assetAdded` assertion each come with this cause and the fix in words. +A group with more than 300 commands (one part with more than about 190 pins) can answer `stillRunning` with a `runId` after about 100 s (Fusion bridge 1.13.64). `fusion schematic` polls until the run ends and checks the final answer. It never sends the list twice. If the run can't be followed to its end, it stops and names the `runId`, because Fusion may still be drawing.+ A failing `analyze` prints `why:` lines with the cause of each field-screen neck and the spec change that fixes it: a via whose copper ring is the narrowest copper (`viaPower`), or another net's track cutting a patch pour off from its pin (`planeUnder`).@@ -165,10 +167,17 @@ LCSC_PN, carried from the library through the schematic) and writes `bom.csv`, o `bom-jlcpcb.csv`. It names any part missing a distributor number. `sourcing check` still wants the sourcing decision (a source column and dated stock), which belongs to the design, not the board. +## Inner copper layers++Needs Fusion bridge 1.13.64 or later. `land route` sends tracks to In<n>.Cu by name and drops vias between layers. `land pours` draws a pour on any copper layer. The routing state keeps inner tracks, and `measure` reports each inner layer's fill under its own name.++A board that an older Fusion bridge converted to 4 layers can open with a warning that the board file has errors. Call `fusion_set_layer_count` again at the same count to repair its stack. A via drawn before the repair stays invalid (`extent: invalid` in the routing state), so `unroute` that net and land it again.++A plan landed twice with the same outlines adds nothing the second time. A plan with a different outline on the same net and layer adds a second pour, on inner layers as on top and bottom, and `land pours` doesn't remove pours.+ ## What is not in Fusion (yet)  - Keepout zones are not landed; pours are drawn clear of them. `verify-pours` (issue #113) therefore does not look for a keepout as a zone on the board. It checks the area clear instead: no saved pour fill of any net on the keepout's layers may lie inside it, and a pour that reaches into it with no saved fill fails, since nothing shows it clear. Only a pour keepout (`copperPour` alone) can be judged this way, so a track or via keepout is refused in words. The proof lists these under `keepoutsCheckedClear`. They are not adoptable zones, so `--adopt` owns only the matched pours. Altium, which cuts pour keepouts out of its polygons, is judged the same way.-- Copper on inner layers: `land route` sends tracks to In<n>.Cu by name, the routing state keeps inner tracks, and `measure` reports each inner layer's fill under its own name. `land pours` still can't draw on an inner layer, because `fusion_apply_pours` takes top and bottom only: draw inner planes in Fusion yourself. A plan naming an inner pour keeps it if it's already drawn, and refuses in words if not, before anything lands. - Removing copper is by whole net (`unroute`), not by single segment. - Arcs (curved wires) in the source are read as straight segments. 
docs/release-0.2.47.mdadded+15
@@ -0,0 +1,15 @@+# AI Flow 0.2.47 (2026-11-03)++Fusion lane, for Fusion bridge 1.13.64:++- **`land pours` draws on inner layers.** A pour on In1.Cu or any other copper layer now lands like one on top or bottom. AI Flow used to refuse it with `inner_pour_unsupported` unless you had drawn it by hand.+- **`fusion schematic` follows a long list to its end.** A group past 300 commands (one part with more than about 190 pins) can answer `stillRunning` with a `runId` and no nets after about 100 s. AI Flow used to read that as finished, then send the next group while Fusion was still drawing. It now polls until the run ends, checks the final answer, and never sends a list twice.+- **Routes with vias between layers work on a converted board** with bridge 1.13.64; AI Flow needed no change. See "Inner copper layers" in `docs/fusion-lane.md` for repairing a board an older bridge converted.++Checked live on Fusion with bridge 1.13.64:++- A 4-layer board: a route from F.Cu through In1.Cu and back landed all its tracks and both vias, and read back with through vias on the right layers.+- A pour on In1.Cu and one on F.Cu landed and measured under their own layer names next to an existing In2.Cu plane. Landing the same plan again added nothing.+- 107 parts sent as one list of 762 commands, a test-only merge of the normal groups: Fusion answered `stillRunning`, AI Flow polled for about two minutes, then the pin-by-pin check passed and all 107 parts reached the board.++Not tested live: a real part with more than about 190 pins; the long list came from many small parts.
package.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.46",+  "version": "0.2.47",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
page.json+1−1
@@ -1,7 +1,7 @@ {   "slug": "adom-aiflow",   "type": "app",-  "version": "0.2.46",+  "version": "0.2.47",   "title": "AI Flow",   "description": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. One Rust binary with a crate per step (placement helpers, a grid router with Kelvin taps, pours with keepouts, KiCad's DRC gate, live landing through the KiCad Bridge, copper measurement, current and thermal analysis) and a finish line that refuses an unfinished board. Every command answers with hints for the AI; every turn, its thinking time and every rework loop go into run.jsonl, so Claude, Codex and any other engine are compared on the same flow. KiCad, Fusion 360 and Altium Designer today (the same steps and gates through each tool's bridge); Adom's own web apps next.",   "summary": "Adom's AI Flow: a tool to help the AI follow all of the steps it takes to build a board. The AI thinks its way from placement through routing, pours, current and thermal analysis to a delivered video; the binary does the fast, deterministic parts of every step, hands back hints, and keeps a ledger of every turn, every return to an earlier step, and the clock from the prompt to done.",
tools/fusion-project.py+30−2
@@ -1068,9 +1068,34 @@ SHEET_LIMIT_MM = 1950.0 # fusion_schematic_plan puts a block's parts in one row and up to 3 frames in a row of the sheet: 3 frames this wide # stay well inside the limit, and a frame stays small enough to read FRAME_MAX_MM = 400.0-# below the bridge's limit on one fusion_schematic_run (400 before fusion-bridge#137)+# a group small enough to answer as one call; one part with very many pins can still go past it (see run_group) GROUP_MAX_COMMANDS = 380 FRAME_COMMANDS = 10+# a long list answers stillRunning with a runId after about 100 s (fusion-bridge 1.13.64, #137); Adom Bridge+# gives the run 20 minutes. A poll that gets no answer says nothing about the run, so a few are allowed.+RUN_POLL_S = 5+RUN_BUDGET_S = 1260+RUN_DROPPED_POLLS = 6+++def run_group(args, timeout=180):+    """One fusion_schematic_run to its end: the whole reply, however long Fusion takes. The list is sent once.+    A final answer always carries stillRunning false, so a reply without it is a poll that got no answer."""+    r = ab('fusion_schematic_run', args, timeout=timeout)+    rid, start, dropped, polls = r.get('runId'), time.time(), 0, 0+    while r.get('stillRunning') is True:+        if time.time() - start > RUN_BUDGET_S:+            return {'success': False, 'errorCode': 'still_running', 'runId': rid, 'error': 'Fusion was still drawing after %d s' % RUN_BUDGET_S}+        time.sleep(RUN_POLL_S)+        polls += 1+        if polls % 6 == 0: say('still drawing, %d s so far' % (time.time() - start))+        try: p = ab('fusion_schematic_run', {'runId': rid}, timeout=60)+        except subprocess.TimeoutExpired: p = {'error': 'no answer in 60 s'}+        if 'stillRunning' in p: r, dropped = p, 0; continue+        dropped += 1+        if p.get('errorCode') == 'unknown_run_id' or dropped > RUN_DROPPED_POLLS:+            return {'success': False, 'errorCode': 'run_lost', 'runId': rid, 'error': 'polling run %s failed: %s' % (rid, p.get('error'))}+    return r   # An electronics design that was never saved or changed and has no schematic or board open under its name. Anything@@ -1220,7 +1245,7 @@ def cmd_schematic(a):     sheet = {}     for g in plan['groups']:         say('drawing %s: %s' % (g['group'], ', '.join(g['parts'])))-        r = ab('fusion_schematic_run', {'libraryPath': st['libraryPath'], 'commands': g['commands'], 'pauseMs': a.pause_ms}, timeout=180)+        r = run_group({'libraryPath': st['libraryPath'], 'commands': g['commands'], 'pauseMs': a.pause_ms})         saved = keep_reply('run-%02d-%s' % (plan['groups'].index(g) + 1, g['group']), {'group': g['group'], 'parts': g['parts'], 'commands': g['commands'], 'reply': r})         if not r.get('success'): schematic_failure(g, r, saved)         sheet = r.get('nets') or sheet@@ -1290,6 +1315,9 @@ def schematic_failure(g, r, saved):     code = r.get('errorCode') or ''     where = 'The whole reply is in %s.' % saved     retry = 'Close the design without saving (fusion_close_document {save:false}) before you retry.'+    if code in ('still_running', 'run_lost'):+        err('schematic group %s: %s' % (g['group'], r.get('error')),+            ['Fusion may still be drawing this group, so don\'t send the list again. Poll fusion_schematic_run {"runId":"%s"} until stillRunning is false, then read fusion_check_dialogs.' % r.get('runId'), retry, where])     if code == 'add_failed':         err('schematic group %s: the ADD of %s did not land (command %s of the group). %s' % (g['group'], r.get('part'), r.get('failedAt'), r.get('error')),             [ASSET_ADDED, 'The commands before it ran. ' + retry, where])
tools/test_fusion_project.py+50
@@ -275,6 +275,56 @@ class SharedFootprint3D(unittest.TestCase):         self.assertEqual(fp.library_3d_problems(ET.tostring(sch, encoding='unicode')), [])  +class LongRun(unittest.TestCase):+    """fusion_schematic_run past 300 commands can answer stillRunning with a runId and no nets yet (#137)."""+    def drive(self, replies, budget=None):+        calls, real = [], (fp.ab, fp.RUN_POLL_S, fp.RUN_BUDGET_S)+        def fake(verb, args, timeout=600):+            calls.append((verb, args))+            x = replies.pop(0)+            if isinstance(x, Exception): raise x+            return x+        fp.ab, fp.RUN_POLL_S = fake, 0+        if budget is not None: fp.RUN_BUDGET_S = budget+        try: return fp.run_group({'libraryPath': 'L', 'commands': ['ADD;'], 'pauseMs': 0}), calls+        finally: fp.ab, fp.RUN_POLL_S, fp.RUN_BUDGET_S = real++    def test_a_list_that_finishes_in_time_is_one_call(self):+        r, calls = self.drive([{'success': True, 'nets': {'A': ['R1.1']}}])+        self.assertEqual(r['nets'], {'A': ['R1.1']}); self.assertEqual(len(calls), 1)++    def test_it_polls_to_the_whole_reply_and_sends_the_list_once(self):+        r, calls = self.drive([{'success': True, 'stillRunning': True, 'runId': 'x'}, {'success': True, 'stillRunning': True, 'runId': 'x'},+                               {'success': True, 'stillRunning': False, 'runId': 'x', 'nets': {'A': ['R1.1']}}])+        self.assertEqual(r['nets'], {'A': ['R1.1']})+        self.assertEqual(len(calls), 3)+        self.assertIn('commands', calls[0][1])+        for _, args in calls[1:]: self.assertEqual(args, {'runId': 'x'})++    def test_a_failure_at_the_end_of_a_long_run_is_the_real_reply(self):+        r, _ = self.drive([{'success': True, 'stillRunning': True, 'runId': 'x'}, {'success': False, 'stillRunning': False, 'errorCode': 'add_failed', 'part': 'U1'}])+        self.assertEqual((r['success'], r['errorCode'], r['part']), (False, 'add_failed', 'U1'))++    def test_a_dropped_poll_does_not_end_the_run(self):+        r, _ = self.drive([{'success': True, 'stillRunning': True, 'runId': 'x'}, {'success': False, 'error': 'relay hiccup'},+                           fp.subprocess.TimeoutExpired('adom-bridge', 60), {'success': True, 'stillRunning': False, 'nets': {'A': []}}])+        self.assertEqual(r['nets'], {'A': []})++    def test_a_run_the_bridge_has_lost_stops_at_once(self):+        r, calls = self.drive([{'success': True, 'stillRunning': True, 'runId': 'x'}, {'success': False, 'errorCode': 'unknown_run_id', 'error': 'No schematic run x'}])+        self.assertEqual((r['success'], r['errorCode'], r['runId']), (False, 'run_lost', 'x')); self.assertEqual(len(calls), 2)++    def test_polls_that_never_answer_stop_without_resending(self):+        replies = [{'success': True, 'stillRunning': True, 'runId': 'x'}] + [{'success': False, 'error': 'no route'} for _ in range(fp.RUN_DROPPED_POLLS + 1)]+        r, calls = self.drive(replies)+        self.assertEqual(r['errorCode'], 'run_lost')+        self.assertEqual(sum('commands' in a for _, a in calls), 1)++    def test_past_the_budget_it_stops_and_names_the_run(self):+        r, calls = self.drive([{'success': True, 'stillRunning': True, 'runId': 'x'}], budget=-1)+        self.assertEqual((r['success'], r['errorCode'], r['runId']), (False, 'still_running', 'x')); self.assertEqual(len(calls), 1)++ class BlankDesign(unittest.TestCase):     """adom-aiflow#133: only a truly empty electronics design is reused, run against fake documents."""     class Doc:

Comments

No comments yet.

Log in to comment.