tan-cli 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. tan/__init__.py +0 -0
  2. tan/__main__.py +120 -0
  3. tan/cli.py +939 -0
  4. tan/command_table.py +265 -0
  5. tan/commands/__init__.py +8 -0
  6. tan/commands/bootstrap_cmd.py +4856 -0
  7. tan/commands/bootstrap_patches.py +164 -0
  8. tan/commands/bootstrap_toolchain_fetch.py +155 -0
  9. tan/commands/bootstrap_toolchain_pin.py +36 -0
  10. tan/commands/build/__init__.py +1 -0
  11. tan/commands/build/configure_inputs.py +277 -0
  12. tan/commands/build/execute.py +1852 -0
  13. tan/commands/build/host_python.py +59 -0
  14. tan/commands/build/link_stale.py +78 -0
  15. tan/commands/build/manifest.py +428 -0
  16. tan/commands/build/materialise.py +81 -0
  17. tan/commands/build/token_substitution.py +381 -0
  18. tan/commands/build/toolchain.py +482 -0
  19. tan/commands/build/user_defines_wipe.py +115 -0
  20. tan/commands/build/workspace_patches.py +65 -0
  21. tan/commands/build_cmd.py +2236 -0
  22. tan/commands/build_output.py +331 -0
  23. tan/commands/clean_cmd.py +1183 -0
  24. tan/commands/completion_cmd.py +786 -0
  25. tan/commands/debug_config_cmd.py +2425 -0
  26. tan/commands/diff_cmd.py +940 -0
  27. tan/commands/doctor_cmd.py +4767 -0
  28. tan/commands/examples_cmd.py +644 -0
  29. tan/commands/explain_cmd.py +1319 -0
  30. tan/commands/faultdecode_cmd.py +931 -0
  31. tan/commands/flash_atoc_guard.py +240 -0
  32. tan/commands/flash_cmd.py +5731 -0
  33. tan/commands/flash_mram_guard.py +150 -0
  34. tan/commands/flash_ram.py +578 -0
  35. tan/commands/flash_raw.py +583 -0
  36. tan/commands/generate_cmd.py +1456 -0
  37. tan/commands/image_cmd.py +753 -0
  38. tan/commands/init_cmd.py +1740 -0
  39. tan/commands/inspect_cmd.py +377 -0
  40. tan/commands/kconfig_cmd.py +734 -0
  41. tan/commands/model_cmd.py +1514 -0
  42. tan/commands/model_device_cmd.py +351 -0
  43. tan/commands/model_device_live.py +234 -0
  44. tan/commands/model_host_cmd.py +430 -0
  45. tan/commands/model_zoo_cmd.py +363 -0
  46. tan/commands/monitor_actions.py +125 -0
  47. tan/commands/monitor_cmd.py +626 -0
  48. tan/commands/monitor_session.py +505 -0
  49. tan/commands/new_som_cmd.py +1522 -0
  50. tan/commands/pinmux_cmd.py +568 -0
  51. tan/commands/presets_cmd.py +1123 -0
  52. tan/commands/probe_cmd.py +421 -0
  53. tan/commands/reset_cmd.py +354 -0
  54. tan/commands/reset_confirm.py +168 -0
  55. tan/commands/run_cmd.py +637 -0
  56. tan/commands/scaffold_cmd.py +490 -0
  57. tan/commands/sdk_cmd.py +1723 -0
  58. tan/commands/size_cmd.py +1020 -0
  59. tan/commands/support_bundle_cmd.py +1073 -0
  60. tan/commands/trace_cmd.py +341 -0
  61. tan/commands/validate_cmd.py +2009 -0
  62. tan/commands/west_forward_cmd.py +736 -0
  63. tan/commands/workspace_patch_check.py +296 -0
  64. tan/core/__init__.py +1 -0
  65. tan/core/alp_conf_pregen.py +100 -0
  66. tan/core/artifact_provenance.py +240 -0
  67. tan/core/atoc_guard.py +276 -0
  68. tan/core/atoc_guard_messages.py +232 -0
  69. tan/core/atoc_replacement.py +118 -0
  70. tan/core/atomic_write.py +178 -0
  71. tan/core/board_context.py +351 -0
  72. tan/core/board_diagnostic.py +111 -0
  73. tan/core/board_files.py +221 -0
  74. tan/core/board_validator.py +542 -0
  75. tan/core/board_validator_compat.py +196 -0
  76. tan/core/board_validator_run.py +129 -0
  77. tan/core/board_validator_schema.py +186 -0
  78. tan/core/board_validator_skew.py +46 -0
  79. tan/core/board_yaml_edit.py +295 -0
  80. tan/core/board_yaml_pos.py +118 -0
  81. tan/core/bootstrap.py +2572 -0
  82. tan/core/build_plan.py +625 -0
  83. tan/core/consent.py +104 -0
  84. tan/core/console_filter.py +245 -0
  85. tan/core/debug_launch.py +1803 -0
  86. tan/core/devicetree_lint.py +168 -0
  87. tan/core/dir_removal.py +229 -0
  88. tan/core/doctor_git.py +172 -0
  89. tan/core/doctor_libraries.py +253 -0
  90. tan/core/doctor_render.py +185 -0
  91. tan/core/doctor_scope.py +66 -0
  92. tan/core/doctor_setools.py +237 -0
  93. tan/core/doctor_stale.py +180 -0
  94. tan/core/document_guards.py +723 -0
  95. tan/core/dp_id.py +97 -0
  96. tan/core/elf_load.py +63 -0
  97. tan/core/error_catalog.py +171 -0
  98. tan/core/example_catalog.py +499 -0
  99. tan/core/example_facets.py +151 -0
  100. tan/core/faultdecode.py +521 -0
  101. tan/core/flash_plan.py +2501 -0
  102. tan/core/flow_d_report.py +381 -0
  103. tan/core/flow_d_uptime.py +164 -0
  104. tan/core/fs_confine.py +130 -0
  105. tan/core/global_flags.py +230 -0
  106. tan/core/host_python.py +210 -0
  107. tan/core/image_bundle.py +247 -0
  108. tan/core/inert.py +147 -0
  109. tan/core/jlink_binary.py +128 -0
  110. tan/core/jlink_probe.py +481 -0
  111. tan/core/jsonc_splice.py +404 -0
  112. tan/core/launch_provenance.py +257 -0
  113. tan/core/link_refusal.py +76 -0
  114. tan/core/metadata_schema.py +376 -0
  115. tan/core/model_check.py +227 -0
  116. tan/core/model_device.py +449 -0
  117. tan/core/model_doctor.py +198 -0
  118. tan/core/model_host.py +48 -0
  119. tan/core/model_list.py +112 -0
  120. tan/core/model_zoo.py +353 -0
  121. tan/core/module_template.py +340 -0
  122. tan/core/mram_link.py +87 -0
  123. tan/core/new_som_ospi.py +94 -0
  124. tan/core/os_class.py +159 -0
  125. tan/core/patch_paths.py +87 -0
  126. tan/core/pending.py +40 -0
  127. tan/core/plain_zephyr_plan.py +138 -0
  128. tan/core/plan_exec.py +373 -0
  129. tan/core/plan_tokens.py +600 -0
  130. tan/core/probe.py +155 -0
  131. tan/core/probe_plan.py +237 -0
  132. tan/core/proxy.py +168 -0
  133. tan/core/publish.py +31 -0
  134. tan/core/python_floor.py +178 -0
  135. tan/core/ram_run.py +694 -0
  136. tan/core/ram_watch.py +231 -0
  137. tan/core/raw_write.py +160 -0
  138. tan/core/reset_plan.py +56 -0
  139. tan/core/run.py +161 -0
  140. tan/core/scaffold.py +2203 -0
  141. tan/core/scaffold_selftest_identity.py +225 -0
  142. tan/core/sdk_default_registry.py +565 -0
  143. tan/core/sdk_discovery.py +996 -0
  144. tan/core/sdk_removal.py +465 -0
  145. tan/core/serial_actions.py +175 -0
  146. tan/core/serial_capture.py +185 -0
  147. tan/core/serial_url.py +45 -0
  148. tan/core/setools.py +608 -0
  149. tan/core/setools_scratch.py +243 -0
  150. tan/core/shapes.py +194 -0
  151. tan/core/size.py +610 -0
  152. tan/core/som_buildability.py +204 -0
  153. tan/core/som_schema_version.py +62 -0
  154. tan/core/subprocess_env.py +201 -0
  155. tan/core/sysbuild_image.py +94 -0
  156. tan/core/system_manifest.py +827 -0
  157. tan/core/text_layout.py +73 -0
  158. tan/core/timestamp.py +182 -0
  159. tan/core/tool_lookup.py +383 -0
  160. tan/core/toolchain_pin.py +117 -0
  161. tan/core/toolchain_provision.py +1224 -0
  162. tan/core/trusted_path.py +134 -0
  163. tan/core/uboot_breakin.py +154 -0
  164. tan/core/uri_reference.py +415 -0
  165. tan/core/user_defines.py +152 -0
  166. tan/core/venv.py +332 -0
  167. tan/core/west_patches.py +143 -0
  168. tan/core/west_workspace_refusal.py +159 -0
  169. tan/core/zephyr_env.py +124 -0
  170. tan/env.py +185 -0
  171. tan/envelope.py +647 -0
  172. tan/exit_codes.py +12 -0
  173. tan/lazy_typer.py +185 -0
  174. tan/model/__init__.py +2 -0
  175. tan/model/_gen_fixture.py +132 -0
  176. tan/model/adapters/__init__.py +116 -0
  177. tan/model/adapters/cpu.py +26 -0
  178. tan/model/adapters/deepx.py +94 -0
  179. tan/model/adapters/drpai.py +242 -0
  180. tan/model/adapters/ethos_u.py +951 -0
  181. tan/model/adapters/executorch.py +55 -0
  182. tan/model/analyze.py +694 -0
  183. tan/model/build.py +304 -0
  184. tan/model/check.py +682 -0
  185. tan/model/manifest.py +625 -0
  186. tan/model/measure.py +216 -0
  187. tan/model/npu_ops_validate.py +130 -0
  188. tan/model/package.py +125 -0
  189. tan/model/perf.py +635 -0
  190. tan/model/perf_apply.py +721 -0
  191. tan/model/prep.py +193 -0
  192. tan/model/sram_fit.py +502 -0
  193. tan/model/targets.py +374 -0
  194. tan/model/tensorio.py +287 -0
  195. tan/net.py +73 -0
  196. tan/output_format.py +213 -0
  197. tan/planner/__init__.py +219 -0
  198. tan/planner/aperture.py +202 -0
  199. tan/planner/buildplan.py +1092 -0
  200. tan/planner/camera_owner.py +192 -0
  201. tan/planner/cameras.py +132 -0
  202. tan/planner/carveout.py +755 -0
  203. tan/planner/cli.py +274 -0
  204. tan/planner/headers.py +315 -0
  205. tan/planner/kconfig.py +2435 -0
  206. tan/planner/kconfig_symbols.py +473 -0
  207. tan/planner/libraries.py +677 -0
  208. tan/planner/link_target.py +237 -0
  209. tan/planner/linux_ownership.py +216 -0
  210. tan/planner/loader.py +1525 -0
  211. tan/planner/manifest.py +116 -0
  212. tan/planner/memory.py +161 -0
  213. tan/planner/memregion.py +56 -0
  214. tan/planner/models.py +533 -0
  215. tan/planner/orchestrator.py +874 -0
  216. tan/planner/ownership.py +203 -0
  217. tan/planner/partition.py +1160 -0
  218. tan/planner/paths.py +32 -0
  219. tan/planner/plain_slice.py +139 -0
  220. tan/planner/project_emit/__init__.py +60 -0
  221. tan/planner/project_emit/bom_netlist.py +375 -0
  222. tan/planner/project_emit/dts.py +580 -0
  223. tan/planner/project_emit/hw_info.py +221 -0
  224. tan/planner/project_emit/native_sim.py +136 -0
  225. tan/planner/project_emit/west_libs.py +213 -0
  226. tan/planner/project_loader.py +264 -0
  227. tan/planner/sdk_capability.py +203 -0
  228. tan/planner/sdk_compat.py +395 -0
  229. tan/planner/secure.py +350 -0
  230. tan/planner/slugs.py +394 -0
  231. tan/planner/som_metadata.py +325 -0
  232. tan/planner/strict_loaders.py +131 -0
  233. tan/planner/template.py +1037 -0
  234. tan/planner/template_pins.py +535 -0
  235. tan/planner/template_rewrite.py +714 -0
  236. tan/planner/topology.py +208 -0
  237. tan/planner/validate.py +365 -0
  238. tan/planner/whole_device_alias.py +67 -0
  239. tan/planner/zephyr_board.py +3510 -0
  240. tan/planner_cli.py +97 -0
  241. tan/planner_emit.py +617 -0
  242. tan/planner_root.py +119 -0
  243. tan/soc_ref.py +58 -0
  244. tan/templates/__init__.py +34 -0
  245. tan/templates/vendored/MANIFEST.md +1862 -0
  246. tan/templates/vendored/diagnostics/E1M-AEN801/CMakeLists.txt +8 -0
  247. tan/templates/vendored/diagnostics/E1M-AEN801/README.md +112 -0
  248. tan/templates/vendored/diagnostics/E1M-AEN801/board.yaml +38 -0
  249. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.conf +11 -0
  250. tan/templates/vendored/diagnostics/E1M-AEN801/boards/native_sim_native_64.overlay +29 -0
  251. tan/templates/vendored/diagnostics/E1M-AEN801/prj.conf +5 -0
  252. tan/templates/vendored/diagnostics/E1M-AEN801/src/main.c +266 -0
  253. tan/templates/vendored/diagnostics/E1M-AEN801/testcase.yaml +53 -0
  254. tan/templates/vendored/diagnostics/E1M-V2N101/CMakeLists.txt +8 -0
  255. tan/templates/vendored/diagnostics/E1M-V2N101/README.md +112 -0
  256. tan/templates/vendored/diagnostics/E1M-V2N101/board.yaml +38 -0
  257. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.conf +11 -0
  258. tan/templates/vendored/diagnostics/E1M-V2N101/boards/native_sim_native_64.overlay +29 -0
  259. tan/templates/vendored/diagnostics/E1M-V2N101/prj.conf +5 -0
  260. tan/templates/vendored/diagnostics/E1M-V2N101/src/main.c +266 -0
  261. tan/templates/vendored/diagnostics/E1M-V2N101/testcase.yaml +53 -0
  262. tan/templates/vendored/edge-ai/E1M-AEN801/CMakeLists.txt +10 -0
  263. tan/templates/vendored/edge-ai/E1M-AEN801/README.md +75 -0
  264. tan/templates/vendored/edge-ai/E1M-AEN801/board.yaml +38 -0
  265. tan/templates/vendored/edge-ai/E1M-AEN801/prj.conf +8 -0
  266. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.c +371 -0
  267. tan/templates/vendored/edge-ai/E1M-AEN801/src/cold_chain.h +178 -0
  268. tan/templates/vendored/edge-ai/E1M-AEN801/src/main.c +373 -0
  269. tan/templates/vendored/edge-ai/E1M-AEN801/testcase.yaml +62 -0
  270. tan/templates/vendored/edge-ai/E1M-V2N101/CMakeLists.txt +10 -0
  271. tan/templates/vendored/edge-ai/E1M-V2N101/README.md +75 -0
  272. tan/templates/vendored/edge-ai/E1M-V2N101/board.yaml +37 -0
  273. tan/templates/vendored/edge-ai/E1M-V2N101/prj.conf +8 -0
  274. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.c +371 -0
  275. tan/templates/vendored/edge-ai/E1M-V2N101/src/cold_chain.h +178 -0
  276. tan/templates/vendored/edge-ai/E1M-V2N101/src/main.c +373 -0
  277. tan/templates/vendored/edge-ai/E1M-V2N101/testcase.yaml +62 -0
  278. tan/templates/vendored/iot/E1M-AEN801/CMakeLists.txt +14 -0
  279. tan/templates/vendored/iot/E1M-AEN801/README.md +103 -0
  280. tan/templates/vendored/iot/E1M-AEN801/board.yaml +69 -0
  281. tan/templates/vendored/iot/E1M-AEN801/prj.conf +15 -0
  282. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.c +196 -0
  283. tan/templates/vendored/iot/E1M-AEN801/src/cc3501e_bridge.h +173 -0
  284. tan/templates/vendored/iot/E1M-AEN801/src/main.c +262 -0
  285. tan/templates/vendored/iot/E1M-AEN801/testcase.yaml +68 -0
  286. tan/templates/vendored/minimal/E1M-AEN801/CMakeLists.txt +8 -0
  287. tan/templates/vendored/minimal/E1M-AEN801/README.md +108 -0
  288. tan/templates/vendored/minimal/E1M-AEN801/board.yaml +32 -0
  289. tan/templates/vendored/minimal/E1M-AEN801/prj.conf +8 -0
  290. tan/templates/vendored/minimal/E1M-AEN801/src/main.c +111 -0
  291. tan/templates/vendored/minimal/E1M-AEN801/testcase.yaml +49 -0
  292. tan/templates/vendored/minimal/E1M-V2N101/CMakeLists.txt +8 -0
  293. tan/templates/vendored/minimal/E1M-V2N101/README.md +108 -0
  294. tan/templates/vendored/minimal/E1M-V2N101/board.yaml +32 -0
  295. tan/templates/vendored/minimal/E1M-V2N101/prj.conf +8 -0
  296. tan/templates/vendored/minimal/E1M-V2N101/src/main.c +111 -0
  297. tan/templates/vendored/minimal/E1M-V2N101/testcase.yaml +49 -0
  298. tan/templates/vendored/multicore-mailbox/E1M-AEN801/CMakeLists.txt +8 -0
  299. tan/templates/vendored/multicore-mailbox/E1M-AEN801/README.md +120 -0
  300. tan/templates/vendored/multicore-mailbox/E1M-AEN801/board.yaml +57 -0
  301. tan/templates/vendored/multicore-mailbox/E1M-AEN801/boards/native_sim_native_64.overlay +40 -0
  302. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/CMakeLists.txt +13 -0
  303. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/main.c +171 -0
  304. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/prj.conf +17 -0
  305. tan/templates/vendored/multicore-mailbox/E1M-AEN801/peer/testcase.yaml +23 -0
  306. tan/templates/vendored/multicore-mailbox/E1M-AEN801/prj.conf +22 -0
  307. tan/templates/vendored/multicore-mailbox/E1M-AEN801/src/main.c +185 -0
  308. tan/templates/vendored/multicore-mailbox/E1M-AEN801/testcase.yaml +26 -0
  309. tan/templates/vendored/sensor/E1M-AEN801/CMakeLists.txt +8 -0
  310. tan/templates/vendored/sensor/E1M-AEN801/README.md +112 -0
  311. tan/templates/vendored/sensor/E1M-AEN801/board.yaml +57 -0
  312. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.conf +10 -0
  313. tan/templates/vendored/sensor/E1M-AEN801/boards/native_sim_native_64.overlay +28 -0
  314. tan/templates/vendored/sensor/E1M-AEN801/prj.conf +5 -0
  315. tan/templates/vendored/sensor/E1M-AEN801/src/main.c +186 -0
  316. tan/templates/vendored/sensor/E1M-AEN801/testcase.yaml +52 -0
  317. tan/templates/vendored/sensor/E1M-V2N101/CMakeLists.txt +8 -0
  318. tan/templates/vendored/sensor/E1M-V2N101/README.md +112 -0
  319. tan/templates/vendored/sensor/E1M-V2N101/board.yaml +57 -0
  320. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.conf +10 -0
  321. tan/templates/vendored/sensor/E1M-V2N101/boards/native_sim_native_64.overlay +28 -0
  322. tan/templates/vendored/sensor/E1M-V2N101/prj.conf +5 -0
  323. tan/templates/vendored/sensor/E1M-V2N101/src/main.c +186 -0
  324. tan/templates/vendored/sensor/E1M-V2N101/testcase.yaml +52 -0
  325. tan/version.py +102 -0
  326. tan_cli-0.7.0.dist-info/METADATA +125 -0
  327. tan_cli-0.7.0.dist-info/RECORD +330 -0
  328. tan_cli-0.7.0.dist-info/WHEEL +5 -0
  329. tan_cli-0.7.0.dist-info/entry_points.txt +2 -0
  330. tan_cli-0.7.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1852 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Dispatch each slice: assemble its env, apply the execution policy's
3
+ skip-vs-fail dispositions, resolve `west` from the workspace venv, spawn the
4
+ tool, stream its output, and report a per-slice outcome -- never an escaping
5
+ exception. Mirrors the dispatch loop in `tan-cli/src/commands/build/execute/
6
+ mod.rs`. `force_pristine` (`tan build --pristine`, tan-cli#427) is the manual
7
+ counterpart to the automatic stamp comparison below: it forces the SAME
8
+ [SdkStampAction.PRISTINE] decision the stamp comparison would make on its
9
+ own, inside the identical two structural safety guards, and reports
10
+ `build.pristine-skipped` (never silent) when one of those guards -- or a
11
+ build dir that was never configured at all -- declines the wipe.
12
+ What IS ported: the unknown-backend / null-command / unsafe-cwd / missing-tool
13
+ skip-vs-fail policy and dispatch order, the build-dir-must-exist-before-the-
14
+ tool-runs precondition, the `tool == "west"` rewrite to the workspace venv's
15
+ own `west` plus the matching PATH prepend (tan-cli#289/#106: `west` normally
16
+ lives ONLY inside the bootstrapped venv, and nothing activates it for a
17
+ GUI-launched editor -- without this every Zephyr slice skipped with `tool
18
+ 'west' not found` on exactly the host the VS Code extension runs on; see
19
+ `tan.core.venv`), the sdk-switch-pristine guard (issue #52: wipe a slice's
20
+ build dir before dispatch when it was configured against a different SDK
21
+ root than this run resolved, then re-stamp it -- see
22
+ [_maybe_pristine_stale_sdk_build_dir]), (tan-cli#309, upstream tan-cli #97)
23
+ the Zephyr-boilerplate-loaded guard -- an `os: zephyr` slice that exits 0
24
+ without ever loading Zephyr's CMake boilerplate (`tan.commands.build.
25
+ manifest.zephyr_boilerplate_loaded`) is reported `failed`, not `ok`, since a
26
+ real exit code alone is not evidence the build produced firmware -- (see
27
+ [`last_manifest_write`]) the post-build `system-manifest.yaml` write, and
28
+ (tan-cli#550) the plan's per-slice `postCommands` plus the matching
29
+ baremetal-evidence guard: an `os: baremetal` slice's `command` is a `cmake -S
30
+ ... -B .` that only CONFIGURES, so its `cmake --build .` arrives as a post
31
+ command ([`_run_post_commands`], with skip-vs-fail taken from the plan's own
32
+ `executionPolicy` per the SDK schema and ADR-0001 -- see
33
+ [`_missing_post_tool`]) and its linked output is checked against the plan's own
34
+ `artifacts.outputDir` ([`_baremetal_artefact_refusal`], whose three
35
+ NON-coverages are enumerated there) before the slice may be called built.
36
+
37
+ **tan-cli#307, a DELIBERATE divergence from the frozen Rust oracle.**
38
+ `crates/` is frozen (`docs/ROADMAP.md`'s standing rule), and the oracle's own
39
+ `execute/mod.rs` has the identical bug: `Command::new(&tool).current_dir(&cwd)`
40
+ lets `west` resolve its topdir from `cwd` alone, so an unrelated `.west`
41
+ ABOVE the project wins over the workspace `tan bootstrap` actually created,
42
+ purely because west never consults the workspace tan itself resolved. Fixed
43
+ Python-side only -- see [`_pin_west_workspace`].
44
+
45
+ **tan-cli#510, a second DELIBERATE divergence from the frozen Rust oracle.**
46
+ `execute/mod.rs:696`'s spawn is still `Command::new(&tool)` -- the bare
47
+ identity, handed straight to the platform's own resolver, whose Windows
48
+ search order includes a current-directory step ADR-0020 says an executor
49
+ must not consult. This port instead resolves `tool` to an absolute path with
50
+ a hardened lookup FIRST ([`_resolve_tool`]) and spawns that -- see this
51
+ module's own `_ToolResolution` docstring for why one resolver, not two. Not
52
+ back-ported to `crates/`, which ships to nobody (`docs/ROADMAP.md`); fixed
53
+ Python-side only.
54
+
55
+ **tan-cli#567, a third DELIBERATE divergence from the frozen Rust oracle** --
56
+ the one above, widened, and recorded here so the register stays complete.
57
+ Two things in this file still handed the platform a bare identity after #510.
58
+ (1) `_terminate`'s Windows `taskkill`: `CreateProcess` reaches
59
+ `%SystemRoot%\\System32` only AFTER the current directory, so a cancelled build
60
+ in a project carrying its own `taskkill.exe` ran that one -- now resolved
61
+ first, see [`_taskkill_program`]. (2) The lookup itself, on POSIX: an EMPTY
62
+ `PATH` entry (`PATH="$PATH:"`, `PATH=":$PATH"` -- both routine) means "the
63
+ current directory" to every POSIX consumer, and `shutil.which` duly joins `""`
64
+ with the name and probes it relative to cwd. Measured on `dev`,
65
+ `_resolve_tool("cwdprobe", {"PATH": ":/nope:"})` answered the RELATIVE string
66
+ `'cwdprobe'` for a file that existed only in the process's own working
67
+ directory, which `execute_slices` then spawned -- live on this path since #510,
68
+ because the Windows branch already filtered empty entries and the POSIX branch
69
+ did not. Empty entries are dropped before the walk; the same tree now answers
70
+ `None`. See `tan.core.tool_lookup.resolve_tool`, which is where that walk now
71
+ lives (this module keeps `_ToolResolution`/`_resolve_tool` as re-export
72
+ aliases). Not back-ported to `crates/`; fixed Python-side only.
73
+
74
+ **How the post-build write reaches `tan run` without a `build_cmd.py` change.**
75
+ The Rust oracle's executor (`execute/mod.rs::execute_slices_outcome`) returns
76
+ a `NativeBuildOutcome` bundling the dispatch result with the write's two
77
+ signals, and `commands::run` consumes that value directly. This port's
78
+ `execute_slices` is reached only through `tan.commands.build_cmd._dispatch` /
79
+ `_build`, both out of THIS unit's scope (a parallel, disjoint-files
80
+ work-split, not an architecture choice) -- so widening `execute_slices`'s
81
+ return type would need `_dispatch`'s own unpacking (`iter(execute_slices(...))`
82
+ / `next(dispatched)`) to change too. Instead the write still happens as a
83
+ side effect of every `execute_slices` call (so `tan build`'s own CLI
84
+ invocation gets the file on disk exactly like `tan run`'s does), and the two
85
+ signals are exposed through [`last_manifest_write`] -- a same-process
86
+ recorder, not a return value. See that function's docstring for why reading
87
+ it is still safe against the R1 staleness defect the Rust oracle's own module
88
+ doc records (`decide_run_action` never consults it unless `build_ok`, and
89
+ `build_ok` cannot be `True` without `execute_slices` having just run to
90
+ completion in the SAME synchronous call)."""
91
+ import os
92
+ import queue
93
+ import shutil
94
+ import subprocess
95
+ import threading
96
+ from collections.abc import Callable, Mapping, Sequence
97
+ from dataclasses import dataclass
98
+ from pathlib import Path
99
+
100
+ from tan.commands.build.user_defines_wipe import reconcile_user_defines, stamp_user_defines
101
+ from tan.core.user_defines import insert_after_dashdash
102
+ from tan.commands.build.configure_inputs import (
103
+ discover_configure_inputs,
104
+ read_configure_inputs_stamp,
105
+ resolve_zephyr_discovery_dir,
106
+ write_configure_inputs_stamp,
107
+ )
108
+ from tan.commands.build.manifest import (
109
+ build_dir_overridden,
110
+ cmake_cache_configured,
111
+ read_sdk_stamp,
112
+ resolve_zephyr_artefact,
113
+ write_post_build_manifest,
114
+ write_sdk_stamp,
115
+ zephyr_boilerplate_loaded,
116
+ )
117
+ from tan.commands.build.materialise import MaterialiseError, confine_to_build_root
118
+ from tan.commands.build.toolchain import host_scan_has_toolchain, verified_store_dir
119
+ from tan.core.plan_exec import (
120
+ CROSS_DRIVE_MSG,
121
+ ExecutionPolicy,
122
+ PolicyAction,
123
+ SdkStampAction,
124
+ assemble_slice_env,
125
+ cross_drive_source_refusal,
126
+ missing_tool_message,
127
+ pristine_suppression,
128
+ resolve_action,
129
+ sdk_stamp_action,
130
+ sdk_stamp_key,
131
+ )
132
+ from tan.core.plain_zephyr_plan import plain_route_target
133
+ from tan.core.subprocess_env import spawn_env
134
+ from tan.core.system_manifest import SliceRunResult
135
+ from tan.core.tool_lookup import ToolResolution, resolve_tool
136
+ from tan.core.venv import west_program, west_workspace_dir, with_venv_on_path
137
+ from tan.core.west_workspace_refusal import workspace_unresolved_refusal
138
+ from tan.core.zephyr_env import zephyr_env_overrides
139
+ from tan.envelope import Issue
140
+ from tan.commands.build.link_stale import insert_after_separator, stale_itcm_overlay_reset
141
+
142
+ if os.name != "nt":
143
+ import signal
144
+
145
+ # metadata/schemas/build-plan-v1.schema.json: slices[].backend enum. Rust's
146
+ # `Backend` (tan-core/src/build_plan.rs) matches this exactly, plus a catch-all
147
+ # `Unknown(String)` for anything else -- "native" is NOT a legal backend; a
148
+ # slice naming it must be refused by `executionPolicy.unknownBackend`
149
+ # (default fail), never dispatched.
150
+ KNOWN_BACKENDS = frozenset({"zephyr", "yocto", "baremetal"})
151
+
152
+ #: `tan_core::build_plan::CONSUMER_BUILD_ROOT` -- the top-level dir name a
153
+ #: slice's plan-supplied (relative, pre-`confine_to_build_root`) `cwd` must
154
+ #: start with before the sdk-switch-pristine guard will touch it. Duplicated
155
+ #: rather than imported to avoid a `tan.core.build_plan` <-> here cycle risk;
156
+ #: it is the SDK's own build-plan schema constant, not derived from anything
157
+ #: in this module.
158
+ _CONSUMER_BUILD_ROOT = "build"
159
+
160
+ # How often the output-draining loop checks `cancelled()` -- bounds
161
+ # cancellation latency without spinning the CPU.
162
+ _POLL_INTERVAL_S = 0.05
163
+ # How long to give a terminated process to exit on its own before escalating
164
+ # to a forceful kill.
165
+ _TERMINATE_GRACE_S = 1.0
166
+
167
+ #: Envelope-wire status vocabulary, from `SliceOutcome.status` -- verbatim
168
+ #: `tan.commands.build_cmd._WIRE_STATUS` (duplicated rather than imported:
169
+ #: `build_cmd.py` already imports this module, so the reverse import would be
170
+ #: circular). Also the manifest schema's own `slices[].status` vocabulary
171
+ #: (`ok`/`failed`/`skipped`), which is why [`_write_manifest_after_dispatch`]
172
+ #: reuses it for the post-build overlay too.
173
+ _WIRE_STATUS = {"succeeded": "ok", "skipped": "skipped"}
174
+
175
+ #: tan-cli#336: west's own literal message (`west/util.py::west_topdir`) when
176
+ #: neither an ancestor `.west` nor `$ZEPHYR_BASE` resolves a workspace --
177
+ #: watched for on a west slice's stdout so a failure carrying it can be
178
+ #: re-worded with what tan itself resolved (see [`execute_slices`]'s dispatch
179
+ #: loop). Substring match, not the whole line: west prefixes it with
180
+ #: "FATAL ERROR: ".
181
+ _WEST_NO_WORKSPACE_MSG = "Could not find a west workspace"
182
+
183
+ #: The Zephyr SDK cross toolchain is absent -- watched for on the same stdout,
184
+ #: for the same reason (tan-cli#419). Zephyr's own CMake says
185
+ #:
186
+ #: CMake Error at <zephyr>/cmake/modules/FindZephyr-sdk.cmake:160
187
+ #: (find_package): Could not find a package configuration file provided
188
+ #: by "Zephyr-sdk" (requested version 1.0)
189
+ #:
190
+ #: and the slice then exits 1, so without this the whole diagnosis is a bare
191
+ #: `terminated with exit code: 1` plus 4 KB of pass-through CMake text the
192
+ #: envelope never names. tan ALREADY HOLDS the answer -- `doctor`'s `zephyrSdk`
193
+ #: check reports it with the exact remedy -- so this is not a missing
194
+ #: capability, it is one the failing command does not surface. Measured on the
195
+ #: same host at the same moment: `doctor --build` said `zephyrSdk -> fail` with
196
+ #: the install command, while `build`'s envelope contained neither
197
+ #: "zephyr sdk" nor "sdk install".
198
+ #:
199
+ #: Matched on the package name rather than the `FindZephyr-sdk.cmake` path,
200
+ #: which carries the workspace's absolute location and a line number that
201
+ #: moves with upstream Zephyr.
202
+ _ZEPHYR_SDK_MISSING_MSG = 'provided by "Zephyr-sdk"'
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class SliceOutcome:
207
+ core_id: str
208
+ status: str # "succeeded" | "skipped" | "failed" | "cancelled"
209
+ exit_code: int | None
210
+ message: str | None
211
+ #: Real on-disk `zephyr.elf` path after a successful build (absolute),
212
+ #: fed into the post-build manifest so `size`/`flash`/`run` find
213
+ #: the artefact west actually produced. `None` for every other status.
214
+ output_artefact: str | None = None
215
+ #: Real on-disk build directory (west's nested `<cwd>/build`, absolute).
216
+ #: `None` for every other status.
217
+ build_dir: str | None = None
218
+ #: The absolute path `_resolve_tool` actually resolved `command.tool` to
219
+ #: -- MAJOR 1 of the tan-cli#510 review. `None` when no tool was ever
220
+ #: resolved (unknown-backend/null-command/missing-tool/cwd/mkdir
221
+ #: refusals, all decided before resolution runs) OR when resolution
222
+ #: succeeded but landed on the SAME string the plan already named
223
+ #: (`tool == resolved`) -- information-free by construction, and the
224
+ #: dominant Zephyr shape once `west_program` has already rewritten
225
+ #: `tool` to the workspace venv's own absolute `west`. Carried as its own
226
+ #: field -- `data.slices[].resolvedTool` -- rather than folded into
227
+ #: `message`: `message` is the source of BOTH `data.slices[].reason`
228
+ #: (`build_cmd.py`) and the persisted `system-manifest.yaml`
229
+ #: `slices[].reason` (alp-sdk `metadata/schemas/system-manifest-v1.
230
+ #: schema.json`, "why a slice was skipped/failed") -- a resolved path is
231
+ #: neither, and stuffing it into `reason` silently added a key to every
232
+ #: green build's envelope and on-disk manifest. See `_text_recap` in
233
+ #: `build_cmd.py` for where this is surfaced in default text (failed and
234
+ #: cancelled slices only -- a success confirms the right tool already,
235
+ #: with nothing left to explain).
236
+ #:
237
+ #: tan-cli#510 review round 3, NIT: `None` here is genuinely AMBIGUOUS for
238
+ #: an already-absolute `command.tool` that then FAILS to launch -- both
239
+ #: "never resolved" (an earlier refusal) and "resolved, identical to the
240
+ #: plan's own `tool`" collapse to the same `null`, and `data.slices[]`
241
+ #: carries no separate `tool` key to disambiguate from the envelope
242
+ #: alone. Deliberate, not an oversight: the motivating case this field
243
+ #: exists for is a MISMATCH (a bare identity landing on a different
244
+ #: binary than the plan named) -- an absolute `tool` that fails is
245
+ #: already fully explained by `message`'s `failed to launch \`<tool>\`:
246
+ #: ...`, which names the identity verbatim, so the suppression rule
247
+ #: (`tool == resolved_tool`) is doing its job rather than hiding
248
+ #: something. A future consumer that needs "which identity did the plan
249
+ #: name" independent of "did it resolve to something different" should
250
+ #: get it as its own additive field (mirroring `resolvedTool`'s own
251
+ #: addition), not by overloading this one's `None` further.
252
+ resolved_tool: str | None = None
253
+ #: The text persisted as `system-manifest.yaml` `slices[].reason`, when it
254
+ #: must differ from `message` -- `None` (the common case) means "use
255
+ #: `message` verbatim". Exists ONLY for the missing-tool refusal
256
+ #: (tan-cli#510 review round 3, MAJOR): `message` there carries `-- searched
257
+ #: PATH: <every entry>` so the CUSTOMER'S OWN TERMINAL shows a fix they can
258
+ #: apply themselves (tan-cli#510's own acceptance bar) -- but that same
259
+ #: string, unredacted, is also what a customer pastes into a support
260
+ #: ticket, and `system-manifest.yaml` is a build ARTEFACT that outlives the
261
+ #: run and gets forwarded. Persisting the full `PATH` there leaks machine
262
+ #: layout (private directory names, sometimes credentials-in-paths) to
263
+ #: whoever the ticket reaches. The short form still answers "why" per the
264
+ #: alp-sdk schema's own field description; the searched detail stays
265
+ #: transient (this run's stdout/envelope only) -- see
266
+ #: [`_write_manifest_after_dispatch`]'s use of this field.
267
+ manifest_message: str | None = None
268
+
269
+
270
+ def _skip_or_fail(
271
+ core_id: str, action: PolicyAction, message: str, *, manifest_message: str | None = None
272
+ ) -> SliceOutcome:
273
+ status = "skipped" if action is PolicyAction.SKIP else "failed"
274
+ return SliceOutcome(core_id, status, None, message, manifest_message=manifest_message)
275
+
276
+
277
+ @dataclass
278
+ class _ManifestWriteSignal:
279
+ manifest_written: bool = False
280
+ native_sim_target: bool | None = None
281
+ #: Why the write failed, when it did (`None` on success). A THIRD signal,
282
+ #: kept OUT of [`last_manifest_write`]'s tuple deliberately: that
283
+ #: function's 2-tuple shape is `tan.core.run.decide_run_action`'s own
284
+ #: contract (via `tan.commands.run_cmd`, out of this unit's scope), and
285
+ #: widening it would need that caller's own unpacking to change too.
286
+ #: [`last_manifest_write_failure`] exposes this one separately for
287
+ #: `tan.commands.build_cmd._build`, which folds it into the JSON
288
+ #: envelope's `issues` as the `build.manifest-write-failed` warning --
289
+ #: see that module's own call site.
290
+ write_failed_reason: str | None = None
291
+
292
+
293
+ _last_manifest_write = _ManifestWriteSignal()
294
+
295
+
296
+ def reset_last_manifest_write() -> None:
297
+ """Clear the recorded signal from the most recent [`execute_slices`] call
298
+ in this process. `tan run`'s `_run` calls this immediately before
299
+ invoking the build engine, so a build that never reaches dispatch (an
300
+ early coded refusal, or a monkeypatched stub in a test) reads the honest
301
+ default `(False, None)` below instead of a previous invocation's
302
+ leftover -- see [`last_manifest_write`] for why that default is always
303
+ safe regardless."""
304
+ global _last_manifest_write
305
+ _last_manifest_write = _ManifestWriteSignal()
306
+
307
+
308
+ def last_manifest_write() -> tuple[bool, bool | None]:
309
+ """`(manifest_written, native_sim_target)` from the most recent
310
+ [`execute_slices`] call in this process -- the in-memory signal
311
+ `tan.core.run.decide_run_action` needs, exposed as a same-process
312
+ recorder rather than a return value (see this module's docstring for
313
+ why). Safe against staleness: `decide_run_action` only ever consults
314
+ these two values when `build_ok` is `True`
315
+ (`tan.commands.build_cmd._build`'s own exit code), and `build_ok` can
316
+ only be `True` after `_build` has run [`execute_slices`] to completion in
317
+ THIS SAME synchronous call -- there is no path to a successful build that
318
+ skips dispatch. Pair with [`reset_last_manifest_write`] before a build
319
+ that might not reach dispatch at all."""
320
+ return _last_manifest_write.manifest_written, _last_manifest_write.native_sim_target
321
+
322
+
323
+ def last_manifest_write_failure() -> str | None:
324
+ """Why the most recent [`execute_slices`] call's post-build manifest
325
+ write failed, or `None` on success -- see
326
+ [`_ManifestWriteSignal.write_failed_reason`] for why this is a separate
327
+ accessor rather than a third element of [`last_manifest_write`]'s
328
+ tuple."""
329
+ return _last_manifest_write.write_failed_reason
330
+
331
+
332
+ #: Coded envelope issues from the sdk-switch-pristine guard, one call's
333
+ #: worth -- verbatim `crates/tan-cli/.../execute/mod.rs`'s `sdk_switch_issues`,
334
+ #: which the Rust oracle folds into the JSON envelope (`build.sdk-switch-
335
+ #: pristine` / `build.sdk-switch-pristine-failed`) instead of leaving the
336
+ #: wipe stderr-only: the VS Code extension only ever sees the envelope, not
337
+ #: `on_output`'s stream. Same same-process-recorder pattern as
338
+ #: `_last_manifest_write`, for the same reason (see [`last_sdk_switch_issues`]
339
+ #: and this module's own docstring).
340
+ _last_sdk_switch_issues: list[Issue] = []
341
+
342
+
343
+ def last_sdk_switch_issues() -> list[Issue]:
344
+ """The `build.sdk-switch-pristine`/`build.sdk-switch-pristine-failed`
345
+ issues recorded by the most recent [`execute_slices`] call in this
346
+ process -- always freshly OVERWRITTEN (never appended-to) on every call,
347
+ so a build with nothing to wipe reads back the honest empty list rather
348
+ than a previous invocation's leftover."""
349
+ return list(_last_sdk_switch_issues)
350
+
351
+
352
+ #: Coded envelope issues from the stale-configure-cache guard (tan-cli#655),
353
+ #: one call's worth -- same same-process-recorder pattern as
354
+ #: `_last_sdk_switch_issues`, for the same reason: the VS Code extension only
355
+ #: ever sees the envelope, not `on_output`'s stream, and a `-U` reset that
356
+ #: only ever showed up in stderr would be invisible there.
357
+ _last_configure_cache_issues: list[Issue] = []
358
+
359
+
360
+ def last_configure_cache_issues() -> list[Issue]:
361
+ """The `build.configure-cache-reset` issues recorded by the most recent
362
+ [`execute_slices`] call in this process -- always freshly OVERWRITTEN
363
+ (never appended-to), so a build with nothing to reset reads back the
364
+ honest empty list rather than a previous invocation's leftover."""
365
+ return list(_last_configure_cache_issues)
366
+
367
+
368
+ def _drain_output(
369
+ proc: subprocess.Popen, on_output: Callable[[str], None], cancelled: Callable[[], bool]
370
+ ) -> bool:
371
+ """Stream `proc`'s stdout to `on_output` line by line, polling
372
+ `cancelled()` every iteration -- BEFORE waiting on the next line, not
373
+ only when none is pending. Returns True iff cancellation was observed.
374
+
375
+ Two separate problems, both live here:
376
+
377
+ 1. A plain `for line in proc.stdout:` blocks on the next `readline()`
378
+ with no way to interleave a check -- a slice that produces no output
379
+ at all (e.g. `time.sleep(60)`) would never yield control back. Fixed
380
+ by reading on a background thread and polling a queue with a timeout
381
+ (Windows anonymous pipes don't support `select`).
382
+ 2. Polling `cancelled()` only in the `except queue.Empty:` arm (the
383
+ first cut of this fix) is not enough: a CHATTY child -- `west build`
384
+ driving cmake/ninja is chatty by construction -- keeps the queue
385
+ non-empty forever, so that arm never runs and cancellation is never
386
+ observed. `cancelled()` must be checked unconditionally at the top of
387
+ every iteration, before the queue is touched at all.
388
+ """
389
+ lines: queue.Queue[str | None] = queue.Queue()
390
+
391
+ def reader() -> None:
392
+ try:
393
+ assert proc.stdout is not None
394
+ for line in proc.stdout:
395
+ lines.put(line)
396
+ finally:
397
+ lines.put(None) # sentinel: stream closed
398
+
399
+ threading.Thread(target=reader, daemon=True).start()
400
+
401
+ while True:
402
+ if cancelled():
403
+ return True
404
+ try:
405
+ line = lines.get(timeout=_POLL_INTERVAL_S)
406
+ except queue.Empty:
407
+ continue
408
+ if line is None:
409
+ return False
410
+ on_output(line.rstrip("\n"))
411
+
412
+
413
+ def _terminate(proc: subprocess.Popen) -> None:
414
+ """Kill the whole process TREE, not just the direct child.
415
+
416
+ `west build` spawns cmake, which spawns ninja/make -- each inherits the
417
+ parent's stdout handle (Python's own `close_fds` default only closes fds
418
+ above 2). Killing just `proc` leaves a grandchild holding the pipe's
419
+ write end open: the reader thread's `for line in proc.stdout` never sees
420
+ EOF and blocks forever, and `Popen.__exit__` (which closes stdout) then
421
+ deadlocks on that same block. `start_new_session=True` at spawn (see
422
+ `execute_slices`) puts the child in its own POSIX process group so
423
+ `killpg` reaches every descendant; Windows has no process groups, so
424
+ `taskkill /T` walks the OS-tracked parent-PID tree instead.
425
+
426
+ **tan-cli#567.** That `taskkill` used to be spawned as a BARE `argv[0]`,
427
+ which is the one place in this file #510 did not reach. `CreateProcess`
428
+ with `lpApplicationName=NULL` searches the parent process's CURRENT
429
+ DIRECTORY *before* the system directories, and the current directory here
430
+ is the customer's project -- so a project carrying its own `taskkill.exe`
431
+ got that binary run, with whatever privileges tan has, the moment a build
432
+ was cancelled. `taskkill` really does live in `%SystemRoot%\\System32`,
433
+ which is on every sane `%PATH%`, so the hardened lookup finds the real one;
434
+ the `%SystemRoot%` fallback covers a stripped `%PATH%`, and a host where
435
+ NEITHER answers falls back to killing the direct child rather than spawning
436
+ a name the current directory could supply. That last case loses the
437
+ grandchildren, which is a worse cleanup but not a worse *hazard* -- and it
438
+ is unreachable on any Windows install that still has System32."""
439
+ if os.name == "nt":
440
+ taskkill = _taskkill_program()
441
+ if taskkill is None:
442
+ proc.kill()
443
+ else:
444
+ subprocess.run(
445
+ [taskkill, "/T", "/F", "/PID", str(proc.pid)],
446
+ capture_output=True,
447
+ env=spawn_env(),
448
+ check=False,
449
+ )
450
+ else:
451
+ try:
452
+ pgid = os.getpgid(proc.pid)
453
+ os.killpg(pgid, signal.SIGTERM)
454
+ try:
455
+ proc.wait(timeout=_TERMINATE_GRACE_S)
456
+ except subprocess.TimeoutExpired:
457
+ os.killpg(pgid, signal.SIGKILL)
458
+ except ProcessLookupError:
459
+ pass # already exited between the cancellation check and here
460
+ proc.wait()
461
+
462
+
463
+ def _taskkill_program() -> str | None:
464
+ """The absolute path to Windows' own `taskkill`, or `None`.
465
+
466
+ `%PATH%` first, through the same hardened walk every other spawn in this
467
+ port now uses (`os.curdir` is never consulted); then `%SystemRoot%\\System32`
468
+ directly, because a `%PATH%` stripped of the system directories is a real
469
+ (if unusual) CI shape and losing the process-tree kill there would be a
470
+ regression this fix does not need to cause. `None` only when both miss --
471
+ see [`_terminate`] for why that answers with `proc.kill()` rather than a
472
+ bare name."""
473
+ resolved = resolve_tool("taskkill", os.environ).resolved
474
+ if resolved is not None:
475
+ return resolved
476
+ system32 = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32" / "taskkill.exe"
477
+ return str(system32) if system32.is_file() else None
478
+
479
+
480
+ #: tan-cli#567/#532: the ONE hardened lookup, now in `tan.core.tool_lookup`
481
+ #: so the flash write path and the size path spawn the SAME resolved path
482
+ #: instead of keeping a third and fourth opinion about it. Imported under the
483
+ #: private names this module's call sites (and `tests/commands/test_execute.py`)
484
+ #: already use -- the move is a relocation, not a rename. See that module's
485
+ #: docstring for `_ToolResolution`'s reasoning, which travelled with it.
486
+ _ToolResolution = ToolResolution
487
+ _resolve_tool = resolve_tool
488
+
489
+
490
+ @dataclass(frozen=True)
491
+ class _StepResult:
492
+ """One spawned process's outcome, with its three shapes kept apart.
493
+
494
+ Exactly one is ever populated: `exit_code` (the process ran to completion and
495
+ this is its real exit status, negative on a POSIX signal death),
496
+ `cancelled` (the caller's `cancelled()` went true mid-stream and the
497
+ process tree was terminated), or `launch_error` (the spawn itself raised
498
+ `OSError` -- the tool vanished between the availability check and here,
499
+ is a directory, lacks the executable bit, or is not a valid executable
500
+ format). A bare `int | None` return could not tell the second from the
501
+ third."""
502
+
503
+ exit_code: int | None = None
504
+ cancelled: bool = False
505
+ launch_error: str | None = None
506
+
507
+
508
+ def _spawn_step(
509
+ program: str, args: Sequence[str], cwd: Path, env: dict[str, str],
510
+ on_line: Callable[[str], None], cancelled: Callable[[], bool],
511
+ ) -> _StepResult:
512
+ """Spawn ONE already-resolved program, stream its output, and report how
513
+ it ended -- the single spawn implementation shared by a slice's own
514
+ `command` and by each of its `postCommands` steps (tan-cli#550).
515
+
516
+ Extracted rather than duplicated for the post-build steps: the fd hygiene,
517
+ the process-group setup and the cancellation handshake below are all
518
+ load-bearing and subtly easy to get wrong a second time -- a post-build
519
+ `cmake --build` that ignored `cancelled()` would keep compiling after the
520
+ user pressed Ctrl-C, with tan already reporting the slice cancelled.
521
+
522
+ `program` is always the RESOLVED absolute path (tan-cli#510), never a
523
+ bare identity -- resolution happens in the caller, which is also the only
524
+ place that can report a miss against the right execution policy.
525
+
526
+ A context manager, not a bare `Popen(...)`: it closes stdout/stderr/stdin
527
+ on every exit path (success, cancellation, exception) -- a bare `Popen`
528
+ here leaked the pipe's file object (a `ResourceWarning` under
529
+ `-W error::ResourceWarning`, and a real fd leak across a long run).
530
+
531
+ `start_new_session=True` (POSIX: setsid) puts the child in its own process
532
+ group so `_terminate` can `killpg` the whole tree instead of only the
533
+ direct child; a documented no-op on Windows (subprocess.py's Windows
534
+ `_execute_child` takes and ignores it), where `_terminate` uses
535
+ `taskkill /T` instead."""
536
+ try:
537
+ with subprocess.Popen(
538
+ [program, *args],
539
+ cwd=str(cwd),
540
+ # tan-cli#992: re-applied at the literal spawn, not trusted from
541
+ # the caller alone -- `spawn_env(base=env)` is idempotent when
542
+ # `env` already went through it (as `execute_slices`'s does), so
543
+ # this is defense-in-depth, not a behaviour change, for the one
544
+ # caller that already gets it right, and a real fix for any
545
+ # future caller that doesn't.
546
+ env=spawn_env(base=env),
547
+ stdout=subprocess.PIPE,
548
+ stderr=subprocess.STDOUT,
549
+ text=True,
550
+ encoding="utf-8",
551
+ errors="replace",
552
+ bufsize=1,
553
+ start_new_session=True,
554
+ ) as proc:
555
+ if _drain_output(proc, on_line, cancelled):
556
+ _terminate(proc)
557
+ return _StepResult(cancelled=True)
558
+ return _StepResult(exit_code=proc.wait())
559
+ except OSError as err:
560
+ return _StepResult(launch_error=str(err))
561
+
562
+
563
+ def _cwd_under_build_root(raw_cwd: str | None, cwd: Path, project_root: Path) -> bool:
564
+ """Whether the sdk-switch-pristine wipe may touch this slice's `cwd`.
565
+
566
+ Starts from the Rust oracle's `Path::new(&cmd.cwd).components().next() ==
567
+ CONSUMER_BUILD_ROOT`: a plan cwd of `src/` (still a legal relative path)
568
+ must not let the wipe target land at `<project>/src/build`, which may
569
+ hold files the build never created. That first-component check alone let
570
+ `build/../src/c1` through, and so did a `build/c1` symlink into `src/`
571
+ (tan-cli#1388): `confine_to_build_root` only keeps a cwd inside the
572
+ PROJECT, not inside `build/`. So also refuse any `..` in the plan string,
573
+ and require the RESOLVED cwd to sit at or under `<project>/build` --
574
+ with `<project>/build` itself resolving to exactly that path, so a
575
+ symlinked build root cannot carry the wipe elsewhere."""
576
+ if not raw_cwd:
577
+ return False
578
+ parts = Path(raw_cwd).parts
579
+ if not parts or parts[0] != _CONSUMER_BUILD_ROOT or ".." in parts:
580
+ return False
581
+ project = project_root.resolve()
582
+ build_root = (project / _CONSUMER_BUILD_ROOT).resolve()
583
+ if build_root != project / _CONSUMER_BUILD_ROOT:
584
+ return False
585
+ resolved = cwd.resolve()
586
+ return resolved == build_root or build_root in resolved.parents
587
+
588
+
589
+ def _maybe_pristine_stale_sdk_build_dir(
590
+ core_id: str,
591
+ cwd: Path,
592
+ raw_cwd: str | None,
593
+ cmd_args: Sequence[str],
594
+ sdk_stamp_key_str: str | None,
595
+ on_output: Callable[[str], None],
596
+ *,
597
+ project_root: Path,
598
+ force_pristine: bool = False,
599
+ ) -> list[Issue]:
600
+ """Sdk-switch-pristine guard (issue #52): a build dir west configured
601
+ against a PREVIOUS `--sdk-root` makes it FATAL ERROR on this one ("please
602
+ clean it, use --pristine, or use --build-dir") -- a real failure that
603
+ otherwise reaches the user as a bare "terminated with exit code: 1".
604
+ Detect it here and wipe west's own nested build dir before the tool runs,
605
+ so the configure that follows is fresh instead of fatal, then re-stamp
606
+ the dir with this run's SDK identity BEFORE the tool is spawned (a
607
+ mid-configure failure still stamped correctly, since the dir really was
608
+ configured against it regardless of whether the build finishes).
609
+
610
+ `force_pristine` is `tan build --pristine` (tan-cli#163, wired tan-cli#427):
611
+ unconditionally treats the sdk-switch-pristine decision as
612
+ [`SdkStampAction.PRISTINE`] instead of consulting the stamp, for the
613
+ manual case the automatic stamp comparison doesn't (or can't yet) cover.
614
+ It is NOT a second wipe path -- it forces the SAME decision the automatic
615
+ check already makes, still inside the same two structural safety guards
616
+ below, so it still never touches a build dir tan cannot vouch for. Every
617
+ suppression (either guard, or a build dir that was never configured at
618
+ all) is reported as `build.pristine-skipped` rather than silently doing
619
+ nothing (tan-cli#183) -- "pristine" must never silently mean
620
+ "incremental".
621
+
622
+ Two guards (mirroring `resolve_zephyr_artefact`'s own refusal to trust a
623
+ build dir it cannot resolve) gate the whole function -- detection, wipe,
624
+ AND the stamp write -- so a `-d`/`--build-dir` override or a cwd outside
625
+ `CONSUMER_BUILD_ROOT` never gets touched. Best-effort throughout: a wipe
626
+ or write failure is reported via `on_output` but never raises -- it
627
+ fails toward a spurious future rebuild, never toward trusting a stale
628
+ build dir or crashing the build. Port of `execute/mod.rs`'s inline guard
629
+ (~line 505) plus `manifest.rs::write_sdk_stamp`'s call site.
630
+
631
+ Returns the coded `build.sdk-switch-pristine`/`build.sdk-switch-pristine-
632
+ failed`/`build.pristine-skipped` [`Issue`]s for this slice (verbatim
633
+ `execute/mod.rs`'s `sdk_switch_issues.push`, at `warning` severity like
634
+ the oracle's) -- empty when nothing was wiped and no `--pristine` was
635
+ asked for. `on_output` still gets the same "note: ..." text regardless
636
+ (this port's stderr stream is always-on, unlike the oracle's
637
+ text-mode-only `eprintln!`); the caller folds the returned issues into
638
+ the JSON envelope so the wipe -- or its suppression -- is not
639
+ stderr-only there."""
640
+ overridden = build_dir_overridden(cmd_args)
641
+ under_build_root = _cwd_under_build_root(raw_cwd, cwd, project_root)
642
+ issues: list[Issue] = []
643
+
644
+ # Probed only when `--pristine` was actually passed, so the non-pristine
645
+ # path keeps its existing IO exactly (one `cmake_cache_configured` call
646
+ # below, not two) -- mirrors the oracle's own `pristine_cache_configured`
647
+ # split.
648
+ cache_configured = cmake_cache_configured(cwd) if force_pristine else False
649
+ skipped = pristine_suppression(force_pristine, overridden, under_build_root, cache_configured)
650
+ if skipped is not None:
651
+ message = f"{core_id}: --pristine did NOT wipe the build dir — {skipped.reason()}"
652
+ on_output(f"note: {message}")
653
+ issues.append(Issue("build.pristine-skipped", "warning", message))
654
+
655
+ if overridden or not under_build_root:
656
+ return issues
657
+
658
+ cached = read_sdk_stamp(cwd)
659
+ if force_pristine:
660
+ # Reached only when the dir WAS configured -- a never-configured one
661
+ # was already reported by `pristine_suppression` above, so this arm
662
+ # needs no message of its own for that case.
663
+ action = SdkStampAction.PRISTINE if cache_configured else SdkStampAction.KEEP
664
+ else:
665
+ action = sdk_stamp_action(
666
+ cached, sdk_stamp_key_str, cmake_cache_configured(cwd), overridden, under_build_root
667
+ )
668
+ if action is SdkStampAction.PRISTINE:
669
+ new_root = sdk_stamp_key_str or "?"
670
+ if force_pristine:
671
+ message = f"{core_id}: --pristine passed; wiping build dir before dispatch"
672
+ elif cached is not None:
673
+ message = (
674
+ f"{core_id}: build dir was configured against SDK root `{cached}`; "
675
+ f"active SDK is `{new_root}` — running pristine"
676
+ )
677
+ else:
678
+ message = (
679
+ f"{core_id}: build dir predates the SDK-switch stamp (no recorded "
680
+ f"SDK root); running pristine against the active SDK `{new_root}`"
681
+ )
682
+ on_output(f"note: {message}")
683
+ issues.append(Issue("build.sdk-switch-pristine", "warning", message))
684
+ try:
685
+ shutil.rmtree(cwd / "build")
686
+ except OSError as err:
687
+ # Best-effort: west's own FATAL ERROR below (if the wipe didn't
688
+ # fully land) at least now ships with a note explaining why.
689
+ failed = f"{core_id}: could not fully wipe the stale build dir: {err}"
690
+ on_output(f"note: {failed}")
691
+ issues.append(Issue("build.sdk-switch-pristine-failed", "warning", failed))
692
+
693
+ if sdk_stamp_key_str is not None:
694
+ try:
695
+ write_sdk_stamp(cwd, sdk_stamp_key_str)
696
+ except OSError:
697
+ pass # best-effort -- see this function's docstring
698
+ return issues
699
+
700
+
701
+ #: cmake cache-var reset applied by [`_maybe_reset_stale_configure_cache`] --
702
+ #: see that function's docstring for why exactly these two and no others.
703
+ _CONFIGURE_CACHE_RESET_ARGS = ["-UDTC_OVERLAY_FILE", "-UCONF_FILE"]
704
+
705
+
706
+ def _maybe_reset_stale_configure_cache(
707
+ core_id: str,
708
+ cwd: Path,
709
+ app_dir: str | None,
710
+ backend: str,
711
+ build_root: Path,
712
+ on_output: Callable[[str], None],
713
+ ) -> tuple[list[str], list[Issue]]:
714
+ """tan-cli#655: a newly-added (or removed) devicetree overlay or Kconfig
715
+ fragment at one of Zephyr's own auto-discovery locations (`app.overlay`,
716
+ `boards/*.overlay`, `socs/*.overlay`, `prj*.conf`, `boards/*.conf`,
717
+ `socs/*.conf`) was silently IGNORED by an already-configured build dir --
718
+ `tan build` reported `ok`, but the generated `zephyr.dts`/`.config` still
719
+ reflected the PREVIOUS overlay/fragment set, and only `tan clean` (a full
720
+ wipe) picked the change up.
721
+
722
+ This was NOT a "CMake never reconfigures" bug -- every zephyr slice's
723
+ command already carries at least one `-D` after `--` (`_slice_command`'s
724
+ `-DPython3_EXECUTABLE=${PYTHON}`, always present), so `west build`'s own
725
+ `cmake_opts`-non-empty check (`scripts/west_commands/build.py::do_run`)
726
+ already sets `run_cmake = True` and reconfigures on every `tan build`,
727
+ measured on a real Zephyr 4.4.1 tree, not inferred. The reconfigure was
728
+ ALREADY happening; it just produced a stale result.
729
+
730
+ Root cause, also measured against that same tree: Zephyr's own
731
+ `cmake/modules/configuration_files.cmake` resolves `DTC_OVERLAY_FILE`/
732
+ `CONF_FILE` by auto-discovery ONLY `if(NOT DEFINED ...)`, then
733
+ unconditionally CACHES whatever it found -- including "found nothing" --
734
+ as an ordinary CMake cache entry. Every later configure of the SAME
735
+ build dir sees the var already `DEFINED` from the cache and skips
736
+ discovery entirely, so a file that did not exist at the FIRST configure
737
+ stays invisible to every configure after, no matter how many times
738
+ reconfigure runs, until that one cache entry is cleared. An EDIT to an
739
+ already-discovered file is a different, already-solved case: Zephyr ties
740
+ every file it DID discover into `CMAKE_CONFIGURE_DEPENDS`
741
+ (`dts.cmake`/`kconfig.cmake`), so its own mtime bump alone triggers a
742
+ correct reconfigure -- only the SET of discoverable files changing
743
+ defeats the cache.
744
+
745
+ The reset is `-UDTC_OVERLAY_FILE -UCONF_FILE` on the configure that
746
+ follows a set change -- deliberately NOT `-UEXTRA_DTC_OVERLAY_FILE`/
747
+ `-UEXTRA_CONF_FILE` here. CORRECTION (tan-cli#1350): the old premise that
748
+ those two are re-resolved on every configure regardless of the cache is
749
+ only true of `EXTRA_CONF_FILE`, which every plan re-passes with `-D`.
750
+ `EXTRA_DTC_OVERLAY_FILE` is read with `zephyr_get(... CACHE ...)`, so a
751
+ value passed once survives in `CMakeCache.txt` after the plan stops passing
752
+ it -- see `link_stale.stale_itcm_overlay_reset`, which unsets it for the
753
+ one overlay tan itself ever passes that way. This slice's own command
754
+ already ends
755
+ with `-DEXTRA_CONF_FILE=<build_dir>/alp.conf` (`_slice_command`'s
756
+ per-core Kconfig wiring) -- appending `-UEXTRA_CONF_FILE` AFTER that in
757
+ the same argv would UNSET it instead (measured: `-D`/`-U` on the same
758
+ cache key apply in argv order), silently dropping the per-core fragment
759
+ tan itself wires in on every reset. Verified this narrower pair alone is
760
+ sufficient AND leaves `-DEXTRA_CONF_FILE` intact: reproduced the #655
761
+ bug against a real Zephyr 4.4.1 `west build` (new `app.overlay`, new
762
+ `boards/<board>.conf`, and a removed overlay all silently/fatally
763
+ ignored pre-fix), then confirmed `-UDTC_OVERLAY_FILE -UCONF_FILE`
764
+ appended after an existing `-DEXTRA_CONF_FILE=...` recovers all three
765
+ while the `-DEXTRA_CONF_FILE` fragment's own content still lands.
766
+
767
+ Scoped to `os: zephyr` slices (`DTC_OVERLAY_FILE`/`CONF_FILE` are
768
+ Zephyr-CMake-specific; a yocto/baremetal slice has neither -- and
769
+ `_slice_command`'s zephyr branch is the ONLY place that ever emits one,
770
+ always with `tool: "west"`, so checking `backend` alone is exactly as
771
+ precise as also checking the tool identity, mirroring
772
+ `_maybe_pristine_stale_sdk_build_dir`'s own choice not to gate on tool
773
+ either), and to a build dir CMake has already configured at least once
774
+ (`cmake_cache_configured` -- nothing is cached yet on a first build, so
775
+ there is nothing to reset; this call only lays down the baseline stamp).
776
+ Comparison uses [`tan.commands.build.configure_inputs`]'s existence-only
777
+ fingerprint of the discovery dir's auto-discovery locations, stamped
778
+ inside west's own nested build dir (mirrors the SDK-identity stamp
779
+ above) -- NOT the plan's `command.args`, which name every EXTRA_/-D flag
780
+ tan itself controls but say nothing about a file the CUSTOMER added by
781
+ hand.
782
+
783
+ tan-cli#798: `app_dir` is the plan's `appDir` -- the CONFIGURED path,
784
+ NOT the directory `west build` is actually pointed at (see
785
+ `tan.commands.build_cmd`'s "Note `appDir` is NOT the substituted value"
786
+ docstring). For the scaffolded `app: ./src` layout (96 of 105 alp-sdk
787
+ example core entries, and every `tan init` template but `minimal-app`),
788
+ Zephyr auto-discovers overlays/fragments at the PARENT of `app_dir`, so
789
+ globbing `app_dir` itself found nothing on every build and this guard
790
+ never fired. [`resolve_zephyr_discovery_dir`] anchors `app_dir` on
791
+ `build_root` (a relative `appDir` must not resolve against the `tan`
792
+ process's own CWD -- the same reasoning `_missing_app_dirs` and
793
+ `_substituted_app_dirs` already carry) and applies the identical
794
+ CMakeLists.txt-parent fallback `_zephyr_app_dir`
795
+ (`tan.planner.orchestrator`) uses for the real `west build` positional,
796
+ so this guard's glob lands on the same directory.
797
+
798
+ Returns `(extra_cmake_args, issues)`, mirroring
799
+ [`_maybe_pristine_stale_sdk_build_dir`]'s own return shape: append
800
+ `extra_cmake_args` to the spawn argv (empty when nothing changed);
801
+ `issues` carries the coded `build.configure-cache-reset` note at `info`
802
+ severity so a JSON-mode caller (the VS Code extension, envelope-only)
803
+ can see a reset happened, not just a `text`-mode `on_output` line. Best-
804
+ effort throughout: a stamp read/write failure is "no signal" (read) or
805
+ silently skipped (write), never raised -- fails toward one spurious
806
+ extra reset, never toward trusting a cache this function cannot
807
+ actually verify."""
808
+ if backend != "zephyr" or not app_dir:
809
+ return [], []
810
+ app_dir_path = resolve_zephyr_discovery_dir(app_dir, build_root)
811
+ if not cmake_cache_configured(cwd):
812
+ # Nothing cached yet to poison -- lay down this configure's own
813
+ # baseline so the NEXT build has something to compare against.
814
+ try:
815
+ write_configure_inputs_stamp(cwd, discover_configure_inputs(app_dir_path))
816
+ except OSError:
817
+ pass
818
+ return [], []
819
+
820
+ current = discover_configure_inputs(app_dir_path)
821
+ previous = read_configure_inputs_stamp(cwd)
822
+ try:
823
+ write_configure_inputs_stamp(cwd, current)
824
+ except OSError:
825
+ pass # best-effort -- see this function's docstring
826
+ if previous is not None and previous == current:
827
+ return [], []
828
+
829
+ added = sorted(current - (previous or frozenset()))
830
+ removed = sorted((previous or frozenset()) - current)
831
+ detail = "; ".join(
832
+ part
833
+ for part in (f"added {added}" if added else "", f"removed {removed}" if removed else "")
834
+ if part
835
+ ) or "no prior record"
836
+ message = (
837
+ f"{core_id}: devicetree overlay/Kconfig fragment set changed ({detail}) -- "
838
+ f"resetting Zephyr's cached DTC_OVERLAY_FILE/CONF_FILE so the configure that "
839
+ f"follows re-discovers it (tan-cli#655)"
840
+ )
841
+ on_output(f"note: {message}")
842
+ return list(_CONFIGURE_CACHE_RESET_ARGS), [
843
+ Issue("build.configure-cache-reset", "info", message)
844
+ ]
845
+
846
+
847
+ # tan-cli#697. The refusal itself -- `cross_drive_source_refusal` -- is pure
848
+ # argv/path logic with no IO, so it lives in `tan.core.plan_exec` (imported
849
+ # above) rather than here, matching this repo's own convention of keeping
850
+ # pure decisions out of the command/IO module. `CROSS_DRIVE_MSG` (imported
851
+ # above as well) is matched, not imported, by `build_cmd._cross_drive_issues`
852
+ # to promote a refused slice's `reason` into a coded top-level `issues[]`
853
+ # entry -- same idiom as `_missing_tool_issues`' `` tool `...` not found ``
854
+ # text match, immediately below in this same file's `_ZEPHYR_SDK_MISSING_MSG`
855
+ # /`_WEST_NO_WORKSPACE_MSG` pattern. See `cross_drive_source_refusal`'s own
856
+ # docstring (`tan/core/plan_exec.py`) for the no-op conditions (mirroring
857
+ # `_pin_west_workspace`'s own guard, below) and why `args[-1]` -- this
858
+ # function's first, wrong, implementation -- never located the real source
859
+ # dir on a plan `orchestrator.py` actually emits.
860
+
861
+
862
+ def _pin_west_workspace(
863
+ cwd: Path, args: Sequence[str], workspace_dir: Path | None
864
+ ) -> tuple[Path, list[str]]:
865
+ """tan-cli#307: `west` resolves its own topdir purely from the SPAWNED
866
+ PROCESS's cwd (`west.util.west_topdir`/`west.app.main.WestApp.run`,
867
+ verified against the real package: no env var and no CLI flag override
868
+ it -- `$ZEPHYR_BASE` is consulted only as a last resort, when NOTHING is
869
+ found walking up from cwd at all) -- entirely independent of the binary
870
+ tan already resolved (`west_program`). An ancestor `.west` above the
871
+ project (a second Zephyr checkout, a vendor SDK, an old workspace) wins
872
+ over the workspace `tan bootstrap` actually created purely because west
873
+ never consults anything tan resolved; tan picking the right binary and
874
+ then letting that binary pick the wrong workspace is exactly tan-cli#307.
875
+
876
+ Fixed by making the CHILD's own cwd land inside `workspace_dir` -- but
877
+ that collides with `west build`'s OWN cwd dependency: with no
878
+ `-d`/`--build-dir` in `args` (the plan's normal shape), `west build`
879
+ defaults its build dir to `<cwd>/build`, exactly the location
880
+ [`resolve_zephyr_artefact`] expects afterward. Moving `cwd` to the
881
+ workspace topdir would silently move that output too. Decoupled by
882
+ adding an explicit `-d <original cwd>/build` ourselves before handing
883
+ `cwd` off to the topdir: the effective build location stays identical to
884
+ what `west` would have defaulted to, and `cwd`'s new value only ever
885
+ answers "which workspace is this", never "where do the artefacts land".
886
+
887
+ No-op (returns `cwd`/`args` verbatim) when: no workspace resolved
888
+ (`workspace_dir` is `None` -- the caller then keeps behaving exactly as
889
+ before this fix, matching every other `west_workspace_dir` consumer's
890
+ `None` fallback); the command is not `west build` (`args[0] != "build"`
891
+ -- this pin only knows how to preserve `west build`'s own
892
+ default-build-dir rule, not any other verb); or the plan itself already
893
+ redirects the build dir (`build_dir_overridden`) -- that value may
894
+ already be an absolute path west will honour regardless of `cwd`, or a
895
+ relative one this function has no safe way to re-anchor; either way a
896
+ caller-supplied `-d` is left exactly as given, never doubled up."""
897
+ if workspace_dir is None or not args or args[0] != "build" or build_dir_overridden(args):
898
+ return cwd, list(args)
899
+ return workspace_dir, [args[0], "-d", str(cwd / "build"), *args[1:]]
900
+
901
+
902
+ @dataclass(frozen=True)
903
+ class _PostOutcome:
904
+ """How a slice's `postCommands` ended, when they did not all exit 0.
905
+
906
+ `manifest_message` is the short form persisted as `system-manifest.yaml`
907
+ `slices[].reason` when it must differ from `message` -- the same split, for
908
+ the same reason, as [`SliceOutcome.manifest_message`]: the missing-tool
909
+ refusal's `-- searched PATH: <every entry>` belongs in this run's terminal
910
+ and envelope, never in a build ARTEFACT that outlives the run and gets
911
+ forwarded with a support ticket (tan-cli#615 review, MAJOR 1)."""
912
+
913
+ status: str
914
+ exit_code: int | None
915
+ message: str
916
+ manifest_message: str | None = None
917
+
918
+
919
+ def _missing_post_tool(
920
+ where: str, tool: str, searched: str, policy: ExecutionPolicy | None
921
+ ) -> _PostOutcome:
922
+ """The refusal for a post-build step whose tool is not on this host.
923
+
924
+ Routed through `executionPolicy.missingTool`, exactly like the slice's own
925
+ `command` (tan-cli#615 review, MAJOR 3). An earlier round hardcoded
926
+ `failed` here, arguing that a slice whose command has already run and
927
+ rewritten the build tree cannot honestly be called "skipped". That
928
+ argument loses to two documents this port is bound by, neither of which it
929
+ cited: alp-sdk's `metadata/schemas/build-plan-v1.schema.json` AT THE PINNED
930
+ COMMIT says, in `postCommands`' own description, "`executionPolicy` applies
931
+ to each step exactly as it does to `command`"; and this repo's
932
+ `docs/adr/0001-pmt-contract-decoupling.md` says "`tan` applies the policy
933
+ the plan declares; it does not hardcode a copy of the planner's skip
934
+ rules". A hardcoded disposition here is the exact coupling ADR-0001 exists
935
+ to prevent.
936
+
937
+ tan-cli#550 is not reopened by honouring it: `skipped` is not `built`
938
+ either -- the slice is absent from the `N of M slice(s) built` count and
939
+ carries a reason naming the missing tool. What the safety argument DOES
940
+ buy is kept: the message says the configure already ran, so a `skipped`
941
+ here cannot be misread as "never attempted".
942
+
943
+ Only `missingTool` is reachable per step. `nullCommand` cannot be (a null
944
+ step is refused at parse time, `tan.core.build_plan._post_commands`) and
945
+ `unknownBackend` is a per-slice fact decided before any step runs."""
946
+ action = resolve_action(policy, "missing_tool", PolicyAction.SKIP)
947
+ short = f"{where} cannot run: {missing_tool_message(tool)}"
948
+ return _PostOutcome(
949
+ "skipped" if action is PolicyAction.SKIP else "failed",
950
+ None,
951
+ f"{short} -- searched {searched}. The slice's own configure already ran.",
952
+ manifest_message=short,
953
+ )
954
+
955
+
956
+ def _run_post_commands(
957
+ core_id: str, post_commands: Sequence, build_root: Path, env: dict[str, str],
958
+ on_output: Callable[[str], None], cancelled: Callable[[], bool],
959
+ policy: ExecutionPolicy | None,
960
+ ) -> _PostOutcome | None:
961
+ """Run a slice's `postCommands` in order; `None` when every one of them
962
+ exited 0, else the [`_PostOutcome`] the slice must report.
963
+
964
+ The CONSUMER half of alp-sdk #1344 (tan-cli#550). The planner half landed
965
+ in `tan/planner/` with the #608 re-sync and already emits the step --
966
+ `{"tool": "cmake", "args": ["--build", "."]}` for every `os: baremetal`
967
+ slice -- but nothing here read the key, so such a slice ran only its
968
+ `cmake -S ... -B .` CONFIGURE and reported `ok` over a build tree holding
969
+ `CMakeCache.txt` and no object file, archive or executable.
970
+
971
+ Skip-vs-fail for a missing step tool is the plan's call, not this
972
+ function's -- see [`_missing_post_tool`]. A `cwd` that escapes the build
973
+ root and a spawn that raises stay hard failures, matching what the slice's
974
+ own `command` does with the same two conditions."""
975
+ total = len(post_commands)
976
+ for n, step in enumerate(post_commands, start=1):
977
+ label = " ".join([step.tool, *step.args])
978
+ where = f"slice `{core_id}` post-build step {n} of {total} (`{label}`)"
979
+ try:
980
+ # Same confinement the slice's own `command.cwd` gets: a plan is
981
+ # trusted input, never trusted enough to run a process outside
982
+ # the build root.
983
+ cwd = confine_to_build_root(build_root, step.cwd) if step.cwd else build_root
984
+ except MaterialiseError as err:
985
+ return _PostOutcome("failed", None, f"{where} was refused: {err.message}")
986
+ resolution = _resolve_tool(step.tool, env)
987
+ if resolution.resolved is None:
988
+ return _missing_post_tool(where, step.tool, resolution.searched, policy)
989
+ result = _spawn_step(resolution.resolved, step.args, cwd, env, on_output, cancelled)
990
+ if result.cancelled:
991
+ # Deliberately the same message the slice's own command produces:
992
+ # one Ctrl-C cancelled one slice, and which step was in flight is
993
+ # not what the caller asked.
994
+ return _PostOutcome("cancelled", None, f"slice `{core_id}` cancelled")
995
+ if result.launch_error is not None:
996
+ # Both "the tool vanished since the check above" and "this step's
997
+ # `cwd` does not exist"; the OSError text names which.
998
+ return _PostOutcome("failed", None, f"{where} could not run: {result.launch_error}")
999
+ if result.exit_code != 0:
1000
+ return _PostOutcome(
1001
+ "failed", result.exit_code,
1002
+ f"{where} terminated with exit code: {result.exit_code}",
1003
+ )
1004
+ return None
1005
+
1006
+
1007
+ #: MEASURED, and the reason the guard below is PRESENCE-based rather than
1008
+ #: freshness-based (tan-cli#615 review, MAJOR 2 -- answered by disclosure, with
1009
+ #: the measurement, rather than by the requested mtime check).
1010
+ #:
1011
+ #: A "the artefact must be newer than this run started" rule was implemented
1012
+ #: and then withdrawn, because it cannot tell the two cases apart:
1013
+ #:
1014
+ #: * ORPHANED -- the app's CMakeLists.txt stopped defining the executable
1015
+ #: target, so a previous run's binary lingers in `outputDir` and nothing
1016
+ #: rewrites it. This is the case worth catching.
1017
+ #: * UP TO DATE -- nothing changed, so `cmake --build .` correctly relinks
1018
+ #: nothing and the (still valid, still current) binary keeps its old mtime.
1019
+ #: This is the ordinary incremental rebuild, and it is the common case.
1020
+ #:
1021
+ #: In BOTH the artefact is untouched by this run, so any "newer than the run"
1022
+ #: rule refuses both. Measured end to end: with the freshness check in place, a
1023
+ #: second `tan build` with NO source change at all reported
1024
+ #: `failed: m55_hp [baremetal]` -- a false failure on the commonest workflow
1025
+ #: there is, strictly worse than the defect it was closing.
1026
+ #:
1027
+ #: The other candidate discriminators were measured too, and none separates
1028
+ #: the cases: `CMakeCache.txt`'s mtime is PRESERVED across all three runs
1029
+ #: (fresh / no-op / orphaned) and `Makefile`'s is REWRITTEN in all three, so
1030
+ #: neither is a signal. Comparing the artefact against the app's own source
1031
+ #: tree (make's own out-of-date rule) does separate them, but false-refuses
1032
+ #: whenever any file under `appDir` is touched without triggering a relink --
1033
+ #: a `git checkout`, a README edit, an editor swap file -- and re-deriving
1034
+ #: when an artefact is current is the build system's job, not tan's
1035
+ #: (`docs/adr/0001-pmt-contract-decoupling.md`).
1036
+ #:
1037
+ #: So `cmake --build .` exiting 0 is taken as the build system's own statement
1038
+ #: that the artefact is current, and STALENESS ACROSS A REMOVED TARGET IS A
1039
+ #: DISCLOSED LIMIT of this guard -- see [`_baremetal_artefact_refusal`], where
1040
+ #: it is listed beside the other two.
1041
+
1042
+
1043
+ def _output_dir_has_a_file(out_dir: Path) -> bool:
1044
+ """Whether a slice's output directory holds at least one regular FILE.
1045
+
1046
+ Files only: `rglob` walks directories too, and an empty `CMakeFiles/`
1047
+ left behind by the configure is not an artefact.
1048
+
1049
+ An `OSError` on a single entry (a broken symlink, a file deleted between
1050
+ the walk and the `stat`, a permission hole) skips that entry rather than
1051
+ taking the guard down -- one unreadable file is not evidence either way.
1052
+
1053
+ Presence, not freshness -- see the block comment above this function for
1054
+ the measurement that ruled freshness out."""
1055
+ if not out_dir.is_dir():
1056
+ return False
1057
+ for path in out_dir.rglob("*"):
1058
+ try:
1059
+ if path.is_file():
1060
+ return True
1061
+ except OSError:
1062
+ continue
1063
+ return False
1064
+
1065
+
1066
+ def _baremetal_artefact_refusal(sl, build_root: Path) -> str | None:
1067
+ """Why an `os: baremetal` slice whose tools all exited 0 must still be
1068
+ reported `failed` -- `None` when there is nothing to refuse.
1069
+
1070
+ The second half of tan-cli#550. Running `postCommands` makes a COMPILE or
1071
+ LINK error fail the slice honestly, but it does not close the issue's own
1072
+ headline: an exit code is still not evidence that firmware exists. An app
1073
+ defining no executable target configures, "builds", and exits 0 with an
1074
+ empty output directory -- the issue's "green build and an empty output
1075
+ directory", verbatim.
1076
+
1077
+ The evidence is the plan's own `artifacts.outputDir`, which alp-sdk #1344
1078
+ added alongside `postCommands` and which the baremetal configure pins as
1079
+ `CMAKE_RUNTIME_OUTPUT_DIRECTORY` -- where the SDK itself says the slice's
1080
+ linked executables land, not a convention invented here.
1081
+
1082
+ THREE KNOWN LIMITS, all deliberate, none worked around:
1083
+
1084
+ 1. Silent (`None`) when the plan names no `outputDir`. A plan from an
1085
+ alp-sdk predating #1344 gives this guard nothing to judge by, and
1086
+ inventing a location would fail slices that build fine. Those plans get
1087
+ the `postCommands` half of the fix and not this half.
1088
+ 2. A baremetal slice whose app intentionally links no executable (a pure
1089
+ `add_library` tree) is refused. Same trade the `os: zephyr` boilerplate
1090
+ guard takes -- a core in `cores:` is firmware for that core, and a slice
1091
+ producing nothing flashable has nothing for `tan flash`/`size`/`image`
1092
+ to consume.
1093
+ 3. **A previous run's binary satisfies this guard.** Edit an app's
1094
+ CMakeLists.txt to stop defining its executable target and rebuild IN
1095
+ PLACE, and the orphaned binary from the earlier run is still sitting in
1096
+ `outputDir`; the slice is reported `ok` and `tan flash` would write that
1097
+ stale image as though it were current. Deleting the build directory (or
1098
+ `tan clean`) is the only thing that surfaces it today. This one is a
1099
+ DISCLOSURE, not a design preference: a freshness check was implemented
1100
+ and withdrawn after measurement -- see the block comment above
1101
+ [`_output_dir_has_a_file`] for what was tried and why every candidate
1102
+ either false-failed the ordinary incremental rebuild or duplicated the
1103
+ build system's own out-of-date rule."""
1104
+ rel = sl.artifacts.get("outputDir") if isinstance(sl.artifacts, dict) else None
1105
+ if not isinstance(rel, str) or not rel:
1106
+ return None
1107
+ try:
1108
+ out_dir = confine_to_build_root(build_root, rel)
1109
+ except MaterialiseError as err:
1110
+ return (
1111
+ f"core `{sl.core_id}` is declared `os: baremetal`, but the output directory "
1112
+ f"its plan names cannot be checked: {err.message}"
1113
+ )
1114
+ if _output_dir_has_a_file(out_dir):
1115
+ return None
1116
+ return (
1117
+ f"core `{sl.core_id}` is declared `os: baremetal`, but its build produced no "
1118
+ f"artefact in `{rel}` -- the configure and every post-build step exited 0, yet "
1119
+ f"nothing was linked there. tan pins `CMAKE_RUNTIME_OUTPUT_DIRECTORY` at that "
1120
+ f"directory so `tan flash`/`size`/`image` can find the firmware, so an app whose "
1121
+ f"CMakeLists.txt defines no executable target (`add_executable(...)`) builds "
1122
+ f"nothing this core can run."
1123
+ )
1124
+
1125
+
1126
+ def execute_slices(
1127
+ plan,
1128
+ *,
1129
+ build_root: Path,
1130
+ env_lookup: Callable[[str], str | None],
1131
+ gap_fillers: Sequence[tuple[str, str]],
1132
+ on_output: Callable[[str], None],
1133
+ cancelled: Callable[[], bool] = lambda: False,
1134
+ sdk_root: str | None = None,
1135
+ sdk_root_for_stamp: str | None = None,
1136
+ held_outcomes: Sequence[SliceOutcome] = (),
1137
+ force_pristine: bool = False,
1138
+ slice_refusals: Mapping[str, str] | None = None,
1139
+ user_defines: Mapping[str, Sequence[str]] | None = None,
1140
+ ) -> list[SliceOutcome]:
1141
+ """Dispatch every slice of `plan` and return one [`SliceOutcome`] per
1142
+ slice, in plan order.
1143
+
1144
+ `force_pristine` is `tan build --pristine` (tan-cli#427): threaded
1145
+ straight through to [`_maybe_pristine_stale_sdk_build_dir`] per slice --
1146
+ see that function's own docstring for the wipe/suppression decision it
1147
+ makes.
1148
+
1149
+ `sdk_root_for_stamp` -- the identity the sdk-switch-pristine guard keys
1150
+ its stamp comparison on, when it differs from `sdk_root` (the value the
1151
+ post-build manifest write still uses). Defaults to `sdk_root` itself.
1152
+ The caller (`tan.commands.build_cmd.build`) passes a NORMALIZED,
1153
+ workspace-root-anchored form here (`tan.core.plan_exec.normalize_path`)
1154
+ while leaving `sdk_root` raw -- mirroring the oracle's own split between
1155
+ `normalized_sdk_root_str` (stamp-only) and the unnormalized
1156
+ `resolve_sdk_root` result (`${SDK_ROOT}` token substitution, `sdk`
1157
+ envelope reporting, the manifest emit's checkout lookup): a relative
1158
+ `--sdk-root ../alp-sdk` must key the SAME as the absolute pointer `tan
1159
+ sdk switch` already pinned for the identical checkout (tan-cli#163),
1160
+ but must NOT change what `${SDK_ROOT}` substitutes to or what the
1161
+ manifest emit resolves.
1162
+
1163
+ `slice_refusals` -- `core_id -> message` for slices that must FAIL once
1164
+ they are otherwise about to run (tan-cli#1317); see the use site.
1165
+
1166
+ `held_outcomes` -- outcomes for slices the CALLER already decided not to
1167
+ dispatch (`tan.commands.build_cmd._dispatch` holds back a
1168
+ token-substitution-demoted slice before this function ever sees the
1169
+ plan, tan-cli #89). They are folded into the post-build manifest overlay
1170
+ ([`_write_manifest_after_dispatch`]) alongside this call's own outcomes,
1171
+ but are NOT part of the returned list -- the caller already accounts for
1172
+ them in its own outcome list and return-value shape must not double them
1173
+ up. Omitting a held slice from the overlay would leave its
1174
+ `system-manifest.yaml` entry at a previous run's `status`/
1175
+ `output_artefact` forever, indistinguishable from a slice that actually
1176
+ built (oracle `execute/mod.rs:379-400`)."""
1177
+ policy = plan.execution_policy
1178
+ outcomes: list[SliceOutcome] = []
1179
+ sdk_switch_issues: list[Issue] = []
1180
+ configure_cache_issues: list[Issue] = []
1181
+ # Computed once per build (not per slice): the plan's `sdkCommit` reflects
1182
+ # THIS run's freshly emitted plan, not a per-slice fact. `None` when
1183
+ # `sdk_root` itself is unresolved -- see `sdk_stamp_key`'s docstring.
1184
+ stamp_root = sdk_root_for_stamp if sdk_root_for_stamp is not None else sdk_root
1185
+ sdk_stamp_key_str = sdk_stamp_key(stamp_root, plan.sdk_commit)
1186
+ # tan-cli#307: resolved ONCE for the whole run (same reasoning as
1187
+ # `flash_cmd.py`'s own `workspace_dir` -- see that module's docstring),
1188
+ # keyed on `build_root` like every other `west_workspace_dir`/
1189
+ # `west_program` call in this function already is. `None` when nothing
1190
+ # resolves (CI, an activated venv, the contract harness) -- every west
1191
+ # slice below then keeps its old cwd, matching the pre-fix behaviour
1192
+ # exactly (see [`_pin_west_workspace`]).
1193
+ sdk_root_path = Path(sdk_root) if sdk_root is not None else None
1194
+ workspace_dir = west_workspace_dir(str(build_root), sdk_root_path)
1195
+ # tan-cli#308: port of the oracle's `resolve_zephyr_base` -- the
1196
+ # workspace's own `zephyr/` checkout, filtered to a real directory so a
1197
+ # `workspace_dir` that resolved but was never `west update`d (no
1198
+ # `zephyr/` yet) does not hand `west` a `ZEPHYR_BASE` that does not
1199
+ # exist. `None` propagates through [`zephyr_env_overrides`] as "nothing
1200
+ # to fill", matching every other `workspace_dir` consumer's fallback.
1201
+ zephyr_base = workspace_dir / "zephyr" if workspace_dir is not None else None
1202
+ if zephyr_base is not None and not zephyr_base.is_dir():
1203
+ zephyr_base = None
1204
+ # tan-cli#1209: resolved ONCE for the whole run, same reasoning as
1205
+ # `zephyr_base` just above -- it depends only on `sdk_root` (this run's
1206
+ # checkout), never on a per-slice plan field, so recomputing it inside
1207
+ # the loop below would just repeat the same read+stamp-check on every
1208
+ # slice for no different answer. `None` when `sdk_root` is unresolved,
1209
+ # the manifest is missing/malformed, or the stamped store does not
1210
+ # match this checkout's pin -- [`zephyr_env_overrides`] then fills
1211
+ # nothing, matching today's behaviour exactly.
1212
+ #
1213
+ # tan-cli#1209 review MINOR: also `None` when `host_scan_has_toolchain
1214
+ # (sdk_root)` -- a USABLE `zephyr-sdk*` install (a real cross compiler
1215
+ # inside it, not just a name match) CMake's own prefix scan (or the
1216
+ # user package registry) can already find on this host, independent of
1217
+ # tan's store. tan's own store is `-t arm-zephyr-eabi` ONLY; forcing it
1218
+ # ahead of a fuller, scan-visible host SDK could fail a non-ARM slice
1219
+ # that configured fine unaided, and would make `tan build` trust a
1220
+ # different toolchain than `tan doctor` reports
1221
+ # (`doctor_cmd._zephyr_sdk_scan_roots` ranks that same store LAST, never
1222
+ # first). Checked here, not inside `verified_store_dir` itself: that
1223
+ # function answers "is the pin satisfied", a fact independent of what
1224
+ # else is on the host.
1225
+ #
1226
+ # tan-cli#1209 review MAJOR: `host_scan_has_toolchain` requires an
1227
+ # actual compiler inside the candidate, not merely a directory whose
1228
+ # name STARTS WITH `zephyr-sdk` -- an empty `~/zephyr-sdk-leftover/`
1229
+ # must not silently disable tan's own verified store below.
1230
+ verified_store = (
1231
+ verified_store_dir(sdk_root) if not host_scan_has_toolchain(sdk_root) else None
1232
+ )
1233
+
1234
+ for sl in plan.slices:
1235
+ if sl.backend not in KNOWN_BACKENDS:
1236
+ outcomes.append(
1237
+ _skip_or_fail(
1238
+ sl.core_id,
1239
+ resolve_action(policy, "unknown_backend", PolicyAction.FAIL),
1240
+ f"unknown backend `{sl.backend}`",
1241
+ )
1242
+ )
1243
+ continue
1244
+
1245
+ if sl.command is None:
1246
+ outcomes.append(
1247
+ _skip_or_fail(
1248
+ sl.core_id,
1249
+ resolve_action(policy, "null_command", PolicyAction.SKIP),
1250
+ f"slice `{sl.core_id}` has no command",
1251
+ )
1252
+ )
1253
+ continue
1254
+
1255
+ # Dispatch order below matches the Rust oracle (execute/mod.rs):
1256
+ # unsafe-cwd, then create_dir_all, THEN the missing-tool skip/fail --
1257
+ # an escaping cwd is a plan defect and must stay loud even when the
1258
+ # tool also happens to be missing, not get silently absorbed into
1259
+ # missingTool's default-skip.
1260
+
1261
+ # Plans are trusted input, but writes (and here, the working
1262
+ # directory the tool runs in) stay confined under `build_root` --
1263
+ # same guard `materialise_plan` applies to artefact paths.
1264
+ try:
1265
+ cwd = (
1266
+ confine_to_build_root(build_root, sl.command.cwd)
1267
+ if sl.command.cwd
1268
+ else build_root
1269
+ )
1270
+ except MaterialiseError as err:
1271
+ outcomes.append(SliceOutcome(sl.core_id, "failed", None, err.message))
1272
+ continue
1273
+
1274
+ try:
1275
+ cwd.mkdir(parents=True, exist_ok=True)
1276
+ except OSError as err:
1277
+ outcomes.append(
1278
+ SliceOutcome(
1279
+ sl.core_id, "failed", None, f"cannot create build dir `{cwd}`: {err}"
1280
+ )
1281
+ )
1282
+ continue
1283
+
1284
+ tool = sl.command.tool
1285
+ is_west = tool == "west"
1286
+ if is_west:
1287
+ # tan-cli#289/#106: rewrite a bare `"west"` to the west-capable
1288
+ # workspace venv's own binary -- an explicit different tool the
1289
+ # plan named (an absolute path, or anything not literally
1290
+ # `"west"`) is never touched, matching the Rust oracle's own
1291
+ # `cmd.tool == "west"` exact-match precedence.
1292
+ tool = west_program(str(build_root), sdk_root)
1293
+
1294
+ # tan-cli#697: checked here -- right after `is_west` is known and
1295
+ # BEFORE tool resolution -- rather than down at the `_pin_west_
1296
+ # workspace` call site it mirrors: like the unsafe-cwd escape and
1297
+ # `cwd.mkdir` failure above, a cross-drive layout is a plan/host
1298
+ # defect that will crash regardless of which `west` binary this host
1299
+ # happens to have, so it must not be silently absorbed into
1300
+ # `executionPolicy.missingTool`'s default-skip on a host where
1301
+ # `west` is ALSO not on PATH.
1302
+ if is_west:
1303
+ cross_drive_refusal = cross_drive_source_refusal(workspace_dir, sl.command.args)
1304
+ if cross_drive_refusal is not None:
1305
+ outcomes.append(
1306
+ SliceOutcome(
1307
+ sl.core_id,
1308
+ "failed",
1309
+ None,
1310
+ f"slice `{sl.core_id}` refused before build: "
1311
+ f"{cross_drive_refusal.message}",
1312
+ # tan-cli#697 review: a short form for
1313
+ # `system-manifest.yaml` `slices[].reason` -- see
1314
+ # [`SliceOutcome.manifest_message`]'s own docstring;
1315
+ # same split the missing-tool refusal below already
1316
+ # makes.
1317
+ manifest_message=cross_drive_refusal.manifest_message,
1318
+ )
1319
+ )
1320
+ continue
1321
+
1322
+ # tan-cli#308: the zephyr gap-fillers are computed PER SLICE (not
1323
+ # once for the whole run, unlike `workspace_dir`/`zephyr_base`
1324
+ # themselves) because "plan wins" depends on THIS slice's own
1325
+ # `env`/`env_append_path` -- a heterogeneous plan can have one slice
1326
+ # that already pins `EXTRA_ZEPHYR_MODULES` (an SDK-emitted plan's
1327
+ # `envAppendPath`) alongside one that doesn't, and the caller's
1328
+ # `gap_fillers` merge (`assemble_slice_env`) OVERWRITES a key
1329
+ # unconditionally -- computing this once, outside the loop, would
1330
+ # silently clobber the plan's own richer module list on every slice
1331
+ # that DOES pin it.
1332
+ #
1333
+ # Computed HERE, BEFORE tool resolution -- MAJOR 2 of the tan-cli#510
1334
+ # review: `_resolve_tool` used to run on `os.environ` directly, 46
1335
+ # lines before this assembly ever built the env the spawn below
1336
+ # actually gets, so a plan pinning its own `PATH` in `command.env`
1337
+ # was checked against the PARENT's PATH and then spawned against a
1338
+ # DIFFERENT one -- an identical plan, run with `env: {"PATH":
1339
+ # "<planbin>:..."}` and a different `dualtool` on the parent's own
1340
+ # PATH, resolved and reported the PARENT's copy while the CHILD (had
1341
+ # it been spawned unresolved, pre-fix) would have run the plan's. A
1342
+ # plan that pins `PATH` is asking for that PATH to be used --
1343
+ # matching pre-fix POSIX `Popen`, which always selected the spawned
1344
+ # binary via `os.get_exec_path(env)` (the SPAWN's own env), never
1345
+ # the calling process's.
1346
+ slice_gap_fillers = [
1347
+ *gap_fillers,
1348
+ *zephyr_env_overrides(
1349
+ zephyr_base,
1350
+ sdk_root_path,
1351
+ sl.env,
1352
+ sl.env_append_path,
1353
+ env_lookup,
1354
+ toolchain_store=verified_store,
1355
+ ),
1356
+ ]
1357
+ # Bound to a name rather than inlined into the `update` call: the
1358
+ # tan-cli#336 pop below needs to distinguish a `ZEPHYR_BASE` that
1359
+ # something AUTHORITATIVE put here (the plan's own `env`, or
1360
+ # tan-cli#308's gap filler) from one merely inherited off
1361
+ # `os.environ` -- and after the `update` the merged `env` can no
1362
+ # longer tell those apart.
1363
+ slice_env = dict(
1364
+ assemble_slice_env(sl.env, sl.env_append_path, env_lookup, slice_gap_fillers)
1365
+ )
1366
+ # tan-cli#992: `spawn_env()` restores tan's own bundled
1367
+ # `LD_LIBRARY_PATH` override before this slice's own `env`/
1368
+ # `envAppendPath` overlay -- a build slice spawns `west`/`cmake`/the
1369
+ # toolchain, all system programs, and this is the build path (a
1370
+ # `--flash` runs straight out of a build that just took this env).
1371
+ env = spawn_env()
1372
+ env.update(slice_env)
1373
+ # tan-cli#289/#106: the venv `west` spawns nested `west`/`bitbake`
1374
+ # (via `alp_orchestrate`) that resolve purely via PATH -- without
1375
+ # this they fail to find `west` exactly like the parent process
1376
+ # would have, unless the user activated the venv. A no-op when
1377
+ # `tool` did not resolve to an absolute venv path above.
1378
+ env = with_venv_on_path(env, tool)
1379
+
1380
+ # tan-cli#510: resolve the identity to an absolute path with the
1381
+ # SAME hardened lookup the availability check used to only report a
1382
+ # bool for, and spawn THAT -- never the bare `tool` -- below. `tool`
1383
+ # itself stays exactly what it was (the plan's identity, or west's
1384
+ # venv rewrite just above): `with_venv_on_path` and every message
1385
+ # that follows still needs to say what the PLAN named, not what it
1386
+ # resolved to, and [`_ToolResolution`]'s own docstring is why a
1387
+ # second, divergent resolution must never be written here again.
1388
+ # Resolved against THIS slice's own fully assembled `env`
1389
+ # (MAJOR 2), not `os.environ` -- see the env-assembly comment above.
1390
+ resolution = _resolve_tool(tool, env)
1391
+ if resolution.resolved is None:
1392
+ outcomes.append(
1393
+ _skip_or_fail(
1394
+ sl.core_id,
1395
+ resolve_action(policy, "missing_tool", PolicyAction.SKIP),
1396
+ f"{missing_tool_message(tool)} -- searched {resolution.searched}",
1397
+ # tan-cli#510 review round 3, MAJOR: the full searched-PATH
1398
+ # text stays in `message` (this run's stdout + envelope
1399
+ # `reason`) but must NOT reach the persisted
1400
+ # `system-manifest.yaml` -- see [`SliceOutcome.
1401
+ # manifest_message`]'s own docstring.
1402
+ manifest_message=missing_tool_message(tool),
1403
+ )
1404
+ )
1405
+ continue
1406
+ resolved_tool = resolution.resolved
1407
+ # tan-cli#1317: a per-slice refusal the CALLER decided (no usable host
1408
+ # Python for a `${PYTHON}` slice), applied only HERE -- after the
1409
+ # `null_command` and `missing_tool` skips above, so a slice that would
1410
+ # never have run is still skipped per `executionPolicy` -- and before
1411
+ # the destructive pristine wipe below.
1412
+ refusal = (slice_refusals or {}).get(sl.core_id)
1413
+ if refusal is not None:
1414
+ outcomes.append(SliceOutcome(sl.core_id, "failed", None, refusal))
1415
+ continue
1416
+ # tan-cli#1429: same placement and reasoning as the #1317 refusal just
1417
+ # above. `cwd` is the spawn cwd here: `_pin_west_workspace` only moves
1418
+ # it when `workspace_dir` resolved, and this refusal needs it not to.
1419
+ if is_west:
1420
+ no_workspace = workspace_unresolved_refusal(
1421
+ workspace_dir, list(sl.command.args), cwd, env.get("ZEPHYR_BASE"), sdk_root_path
1422
+ )
1423
+ if no_workspace is not None:
1424
+ outcomes.append(
1425
+ SliceOutcome(
1426
+ sl.core_id,
1427
+ "failed",
1428
+ None,
1429
+ f"slice `{sl.core_id}` refused before build: {no_workspace.message}",
1430
+ manifest_message=no_workspace.manifest_message,
1431
+ )
1432
+ )
1433
+ continue
1434
+ # MAJOR 1 of the tan-cli#510 review: `None` (never surfaced) whenever
1435
+ # resolution landed on the exact string the plan already named --
1436
+ # see [`SliceOutcome.resolved_tool`]'s own docstring.
1437
+ outcome_resolved_tool = resolved_tool if resolved_tool != tool else None
1438
+
1439
+ # Deliberately AFTER the missing-tool skip above (not right after
1440
+ # `cwd.mkdir`): the wipe is destructive, and the tool-availability
1441
+ # check is the only thing standing between "this slice is about to
1442
+ # be rebuilt" and "this slice is about to be skipped" -- running the
1443
+ # wipe first would delete the last good `zephyr.elf` for a rebuild
1444
+ # that then never happens on a host missing `west`.
1445
+ # tan-cli#1382: a changed user `-D` set wipes this slice's build dir
1446
+ # (see `user_defines_wipe`). Before the sdk-switch/--pristine guard so
1447
+ # that guard sees the post-wipe dir; the stamp is written after it,
1448
+ # BEFORE the spawn, because it records what this configure is given.
1449
+ ud_now = list(user_defines.get(sl.core_id, ())) if user_defines else []
1450
+ ud_stampable = False
1451
+ if sl.backend == "zephyr":
1452
+ ud_issues, ud_stampable = reconcile_user_defines(
1453
+ sl.core_id,
1454
+ cwd,
1455
+ ud_now,
1456
+ build_root=build_root,
1457
+ guards_ok=not build_dir_overridden(sl.command.args)
1458
+ and _cwd_under_build_root(sl.command.cwd, cwd, build_root),
1459
+ on_output=on_output,
1460
+ )
1461
+ configure_cache_issues.extend(ud_issues)
1462
+
1463
+ sdk_switch_issues.extend(
1464
+ _maybe_pristine_stale_sdk_build_dir(
1465
+ sl.core_id,
1466
+ cwd,
1467
+ sl.command.cwd,
1468
+ sl.command.args,
1469
+ sdk_stamp_key_str,
1470
+ on_output,
1471
+ project_root=build_root,
1472
+ force_pristine=force_pristine,
1473
+ )
1474
+ )
1475
+
1476
+ if ud_stampable:
1477
+ stamp_user_defines(cwd, ud_now)
1478
+
1479
+ # tan-cli#655: AFTER the sdk-switch-pristine guard, not before -- a
1480
+ # wipe there removes `cwd/build` wholesale (this stamp lives inside
1481
+ # it, same as the SDK stamp), so this call must see the POST-wipe
1482
+ # state to correctly treat a just-wiped dir as "nothing cached yet"
1483
+ # rather than comparing against a stamp that no longer describes
1484
+ # anything on disk.
1485
+ configure_cache_reset_args, new_configure_cache_issues = (
1486
+ _maybe_reset_stale_configure_cache(
1487
+ sl.core_id, cwd, sl.app_dir, sl.backend, build_root, on_output
1488
+ )
1489
+ )
1490
+ configure_cache_issues.extend(new_configure_cache_issues)
1491
+ # tan-cli#1350: `link: itcm` -> `auto` must not keep the ITCM overlay.
1492
+ stale_overlay_args: list[str] = []
1493
+ if sl.backend == "zephyr":
1494
+ stale_overlay_args, stale_overlay_issues = stale_itcm_overlay_reset(
1495
+ cwd, sl.command.args)
1496
+ configure_cache_issues.extend(stale_overlay_issues)
1497
+
1498
+ if is_west and workspace_dir is not None and "ZEPHYR_BASE" not in slice_env:
1499
+ # tan-cli#336: a dangling `$ZEPHYR_BASE` inherited from the
1500
+ # ambient shell (seeded above by `dict(os.environ)`) OUTRANKS the
1501
+ # workspace tan just resolved -- west's own `set_zephyr_base`
1502
+ # (`west/app/main.py`) trusts an already-set `ZEPHYR_BASE`
1503
+ # UNCONDITIONALLY over the manifest-derived one, with no
1504
+ # existence check, so a stale value is never self-corrected.
1505
+ # This is a DIFFERENT hazard than tan-cli#307's, and the cwd pin
1506
+ # above does not cover it: the `build` extension's OWN internal
1507
+ # `west_topdir(self.source_dir)` call (zephyr's
1508
+ # `scripts/west_commands/build.py`) walks from the SLICE'S OWN
1509
+ # app directory, not from cwd. An app inside the workspace tree
1510
+ # (an SDK-bundled shim) still resolves fine via that ancestor
1511
+ # walk regardless of `ZEPHYR_BASE`; the user's own project -- the
1512
+ # normal shape `tan init` scaffolds, a SIBLING of the workspace,
1513
+ # never nested inside it -- has no ancestor `.west` at all, so
1514
+ # that walk falls through to `$ZEPHYR_BASE` and inherits
1515
+ # whatever this process saw. Verified against a real dual-core
1516
+ # Zephyr SDK slice pair: the SDK-shim core builds either way,
1517
+ # the user's-project core fails only when `ZEPHYR_BASE` is
1518
+ # stale-but-set.
1519
+ #
1520
+ # Popping the key (rather than pinning it to a value computed
1521
+ # here) lets west's OWN manifest-project lookup re-derive it --
1522
+ # exactly what already happens when `ZEPHYR_BASE` is unset
1523
+ # (verified: an unset `ZEPHYR_BASE` self-heals via the
1524
+ # manifest's "zephyr"-named project, `zephyr.base-prefer`
1525
+ # unset).
1526
+ #
1527
+ # Guarded on `slice_env`, NOT on `sl.env`: tan-cli#308's
1528
+ # `zephyr_env_overrides` fills this key as a gap filler for
1529
+ # precisely the slices that DON'T pin it themselves, so keying
1530
+ # off the plan alone would pop #308's freshly-computed value on
1531
+ # every slice #308 exists to serve and leave only the pop's
1532
+ # weaker self-heal behind. `slice_env` holds the plan's own env
1533
+ # AND the gap fillers merged, so "present in `slice_env`" is
1534
+ # exactly "something authoritative decided this" -- and only an
1535
+ # ambient, inherited `ZEPHYR_BASE` is dropped. The two fixes
1536
+ # compose in that order: #308 supplies the right value whenever
1537
+ # the workspace has a real `zephyr/`; #336 removes a stale
1538
+ # ambient one for the cases #308 cannot fill (no
1539
+ # `workspace_dir`, or a workspace not yet `west update`d).
1540
+ env.pop("ZEPHYR_BASE", None)
1541
+
1542
+ # tan-cli#307: pin `west build` to the workspace tan resolved rather
1543
+ # than letting west infer one from `cwd` -- see [`_pin_west_workspace`].
1544
+ # Every OTHER use of `cwd` in this function (the pristine guard just
1545
+ # above, `resolve_zephyr_artefact` below) deliberately still reads
1546
+ # the ORIGINAL `cwd`/`sl.command.args`, not these: this pin is a
1547
+ # spawn-time-only concern, not a change to where the build dir
1548
+ # itself lives on disk.
1549
+ spawn_cwd, spawn_args = (
1550
+ _pin_west_workspace(cwd, sl.command.args, workspace_dir)
1551
+ if is_west
1552
+ else (cwd, list(sl.command.args))
1553
+ )
1554
+ # Added to the SPAWN argv only -- `sl.command.args` (read again
1555
+ # below by `resolve_zephyr_artefact`/`build_dir_overridden`) stays
1556
+ # exactly what the plan named, so those checks never see a flag tan
1557
+ # itself injected. Order matters, and it is the REVERSE of what this
1558
+ # said before user `-D` existed (tan-cli#1382): cmake applies `-U`/`-D`
1559
+ # in argv order, so a `-U<key>` AFTER a `-D<key>=...` silently unsets
1560
+ # it (`-D CONF_FILE=prod.conf` on a cache-reset build). Every reset
1561
+ # goes right after `--`, BEFORE all plan and user `-D`, so a `-D` of
1562
+ # the same key always wins.
1563
+ spawn_args = insert_after_dashdash(spawn_args, configure_cache_reset_args)
1564
+ # tan-cli#1350: the stale-ITCM `-U` goes right after `--`, BEFORE every
1565
+ # `-D`, so a later `-DEXTRA_DTC_OVERLAY_FILE` (the plan's own, or a
1566
+ # user's) is applied after the unset and can never be erased by it.
1567
+ spawn_args = insert_after_separator(spawn_args, stale_overlay_args)
1568
+
1569
+ # tan-cli#336: watch the slice's own stdout for west's literal
1570
+ # "could not find a workspace" message so a failure carrying it can
1571
+ # be re-worded below with what tan itself resolved -- the bare
1572
+ # `terminated with exit code: N` a plain west failure gets otherwise
1573
+ # is "the least informative true statement available" (issue #336).
1574
+ # `nonlocal` into a fresh binding scoped to THIS iteration (declared
1575
+ # inside the loop, not hoisted above it), so a later slice's closure
1576
+ # never reads a previous one's leftover.
1577
+ saw_no_workspace = False
1578
+ saw_no_zephyr_sdk = False
1579
+ # `None` means "persist `message` verbatim" -- only a post-build
1580
+ # refusal that carries host detail sets it (tan-cli#615 review,
1581
+ # MAJOR 1). Re-bound per iteration so one slice's redaction can never
1582
+ # be persisted as the next slice's reason.
1583
+ manifest_message: str | None = None
1584
+
1585
+ def _watch_for_no_workspace(line: str) -> None:
1586
+ nonlocal saw_no_workspace, saw_no_zephyr_sdk
1587
+ if _WEST_NO_WORKSPACE_MSG in line:
1588
+ saw_no_workspace = True
1589
+ if _ZEPHYR_SDK_MISSING_MSG in line:
1590
+ saw_no_zephyr_sdk = True
1591
+ on_output(line)
1592
+
1593
+ # tan-cli#550: the spawn itself lives in [`_spawn_step`] so the
1594
+ # post-build steps below run through the SAME Popen/drain/terminate
1595
+ # path as the slice's own command -- one spawn implementation, not
1596
+ # two that can drift on cancellation handling or fd hygiene.
1597
+ step = _spawn_step(
1598
+ resolved_tool, spawn_args, spawn_cwd, env, _watch_for_no_workspace, cancelled
1599
+ )
1600
+ if step.cancelled:
1601
+ outcomes.append(
1602
+ SliceOutcome(
1603
+ sl.core_id,
1604
+ "cancelled",
1605
+ None,
1606
+ f"slice `{sl.core_id}` cancelled",
1607
+ resolved_tool=outcome_resolved_tool,
1608
+ )
1609
+ )
1610
+ continue
1611
+ if step.launch_error is not None:
1612
+ # The tool vanished between the availability check above and
1613
+ # here, is a directory, lacks the executable bit, or is not a
1614
+ # valid executable format -- any of these raise inside
1615
+ # `_spawn_step` rather than at the `_resolve_tool` precheck. A
1616
+ # failed slice, never a crash. The base message already names
1617
+ # both `tool` and `resolved_tool` explicitly (it IS the substance
1618
+ # of what failed, not a redundant echo of it) -- `resolved_tool=`
1619
+ # below still carries the SAME fact in the dedicated structured
1620
+ # field.
1621
+ outcomes.append(
1622
+ SliceOutcome(
1623
+ sl.core_id,
1624
+ "failed",
1625
+ None,
1626
+ f"failed to launch `{tool}` resolved to `{resolved_tool}`: "
1627
+ f"{step.launch_error}",
1628
+ resolved_tool=outcome_resolved_tool,
1629
+ )
1630
+ )
1631
+ continue
1632
+ # Neither cancelled nor a launch failure, so `_spawn_step` reached
1633
+ # `proc.wait()` and `code` is a real integer -- the only two shapes
1634
+ # that leave it `None` are the two `continue`d above.
1635
+ code = step.exit_code
1636
+
1637
+ status = "succeeded" if code == 0 else "failed"
1638
+ if code == 0:
1639
+ message = None
1640
+ elif is_west and saw_no_workspace and workspace_dir is not None:
1641
+ # tan-cli#336: west named no cause beyond its own exit code even
1642
+ # though tan was holding a resolved workspace path the whole
1643
+ # time -- name it, and the `ZEPHYR_BASE` this spawn actually saw.
1644
+ # After tan-cli#308 that value is usually the workspace's own
1645
+ # `zephyr/`; "unset" means #308 had nothing to fill and the #336
1646
+ # pop above ran. Either way it is the fact that distinguishes
1647
+ # "tan pointed west somewhere wrong" from "west never saw what
1648
+ # tan resolved". Plain string interpolation, not `!r`: a Windows
1649
+ # path's backslashes survive unescaped this way, matching every
1650
+ # other path already embedded in this module's messages.
1651
+ seen_zephyr_base = env.get("ZEPHYR_BASE") or "unset"
1652
+ message = (
1653
+ f"slice `{sl.core_id}` terminated with exit code: {code} -- west could not "
1654
+ f"find a workspace; tan resolved `{workspace_dir}`, but the spawned process "
1655
+ f"saw ZEPHYR_BASE={seen_zephyr_base}"
1656
+ )
1657
+ elif saw_no_zephyr_sdk:
1658
+ # tan-cli#419: name the cause and the remedy tan already knows.
1659
+ # tan-cli#1520: the remedy is `tan bootstrap` (verified archive),
1660
+ # not a hand-run `west sdk install -t arm-zephyr-eabi`, whose
1661
+ # setup.sh fetch skips the alp-sdk sha256 pin. Same constant
1662
+ # `doctor`'s `zephyrSdk` check uses, so the two cannot drift.
1663
+ from tan.commands.doctor_cmd import ZEPHYR_SDK_BOOTSTRAP_FIX
1664
+
1665
+ message = (
1666
+ f"slice `{sl.core_id}` terminated with exit code: {code} -- the Zephyr "
1667
+ f"SDK cross toolchain is not installed (CMake could not find the "
1668
+ f"`Zephyr-sdk` package). Run "
1669
+ f"`{ZEPHYR_SDK_BOOTSTRAP_FIX}`, then re-run the build; "
1670
+ f"`tan doctor --build` reports this as `zephyrSdk` with the same fix."
1671
+ )
1672
+ else:
1673
+ message = f"slice `{sl.core_id}` terminated with exit code: {code}"
1674
+
1675
+ # tan-cli#309 (upstream tan-cli #97): a core declared `os: zephyr`
1676
+ # whose CMakeLists.txt never calls `find_package(Zephyr ...)` still
1677
+ # configures and links fine under `west build -b <board>` (CMake
1678
+ # only emits a *dev* warning about the missing `project()` call), so
1679
+ # a real exit code 0 is NOT sufficient evidence -- without this the
1680
+ # out-of-the-box scaffold was reported `[+] ok` for a plain host
1681
+ # binary with no Zephyr in it at all. Checked only on an otherwise-
1682
+ # successful slice (a genuine build failure already speaks for
1683
+ # itself) and skipped when the slice redirects west's own build dir
1684
+ # (`-d`/`--build-dir`), where the evidence lives somewhere this
1685
+ # cannot see -- the same refusal `resolve_zephyr_artefact` below
1686
+ # already makes.
1687
+ if (
1688
+ status == "succeeded"
1689
+ and sl.backend == "zephyr"
1690
+ and not build_dir_overridden(sl.command.args)
1691
+ and not zephyr_boilerplate_loaded(cwd)
1692
+ ):
1693
+ status = "failed"
1694
+ message = (
1695
+ f"core `{sl.core_id}` is declared `os: zephyr`, but the build in "
1696
+ f"`{sl.command.cwd or '.'}` never loaded Zephyr (no ZEPHYR_BASE in its "
1697
+ f"CMakeCache.txt and no zephyr/ output) — its CMakeLists.txt must call "
1698
+ f"`find_package(Zephyr REQUIRED HINTS $ENV{{ZEPHYR_BASE}})` before `project()`; "
1699
+ f"without it CMake builds a plain host binary, not firmware. Scaffold a working "
1700
+ f"app with `tan init --template zephyr-app`, or point the core's `app:` at one "
1701
+ f"that does."
1702
+ )
1703
+
1704
+ # tan-cli#550, half 1: run the plan's `postCommands`. `west build` and
1705
+ # `bitbake` configure AND build in one invocation and carry none; a
1706
+ # baremetal slice's `cmake -S ... -B .` only CONFIGURES, so its
1707
+ # `cmake --build .` arrives here and MUST run before the slice can be
1708
+ # called built. Gated on the slice's own command having succeeded --
1709
+ # there is nothing to build on top of a failed configure -- and on the
1710
+ # zephyr-evidence guard above, for the same reason.
1711
+ if status == "succeeded" and sl.post_commands:
1712
+ post = _run_post_commands(
1713
+ sl.core_id, sl.post_commands, build_root, env, on_output, cancelled, policy
1714
+ )
1715
+ if post is not None:
1716
+ # The failing step's own exit code becomes the slice's `rc`:
1717
+ # unlike the evidence guards, this is not tan refusing a
1718
+ # result, it is a real process that really failed. A step that
1719
+ # never reached a process at all carries `None`, which the
1720
+ # single `SliceOutcome` construction below already renders as
1721
+ # a null `rc`.
1722
+ #
1723
+ # No early `continue` for the `cancelled`/`skipped` shapes:
1724
+ # every path from here to that construction is gated on
1725
+ # `status == "succeeded"`, so a non-succeeded post outcome
1726
+ # already reaches it untouched. A second append site would be
1727
+ # an untestable duplicate of the first (proven: a mutant that
1728
+ # removed the `skipped` case from such a branch changed no
1729
+ # observable field).
1730
+ status, code, message = post.status, post.exit_code, post.message
1731
+ # tan-cli#615 review, MAJOR 1: the missing-tool refusal's
1732
+ # searched-PATH text must not reach the persisted
1733
+ # `system-manifest.yaml` -- see [`SliceOutcome.manifest_message`].
1734
+ manifest_message = post.manifest_message
1735
+
1736
+ # tan-cli#550, half 2: an exit code is not evidence that firmware
1737
+ # exists. See [`_baremetal_artefact_refusal`] -- the same shape as the
1738
+ # zephyr guard above, keyed on the plan's own `artifacts.outputDir`.
1739
+ if status == "succeeded" and sl.backend == "baremetal":
1740
+ refusal = _baremetal_artefact_refusal(sl, build_root)
1741
+ if refusal is not None:
1742
+ status = "failed"
1743
+ message = refusal
1744
+
1745
+ # On success, resolve the real on-disk artefact west produced so the
1746
+ # post-build manifest points downstream consumers (`run`/`size`/
1747
+ # `flash`/`image`) at the elf that exists, not a plan-time guess.
1748
+ # Gated on the FINAL `status` (after the guard above), not the raw
1749
+ # exit code: a guard-failed slice has no real Zephyr artefact to
1750
+ # report even though the tool itself exited 0.
1751
+ output_artefact, slice_build_dir = (
1752
+ resolve_zephyr_artefact(cwd, sl.command.args) if status == "succeeded" else (None, None)
1753
+ )
1754
+ outcomes.append(
1755
+ SliceOutcome(
1756
+ sl.core_id,
1757
+ status,
1758
+ # A negative POSIX return code means the process died from a
1759
+ # signal -- Rust's `ExitStatus::code()` returns `None` for
1760
+ # that case (it has no single-integer exit code), so the
1761
+ # envelope's `rc` must be null too, not the raw `-N`. Stays
1762
+ # the tool's REAL exit code even when the guard above
1763
+ # overrode `status` to "failed" -- `west build` really did
1764
+ # exit 0, the guard is refusing the RESULT, not the exit.
1765
+ #
1766
+ # `code` is also `None` when a post-build step (tan-cli#550)
1767
+ # never reached a process at all -- its tool was missing, its
1768
+ # `cwd` escaped the build root, or the spawn raised -- which
1769
+ # is the same "no single-integer exit code" case the signal
1770
+ # death above is, and reaches the envelope's `rc` as null the
1771
+ # same way.
1772
+ None if code is None or code < 0 else code,
1773
+ message,
1774
+ output_artefact,
1775
+ slice_build_dir,
1776
+ resolved_tool=outcome_resolved_tool,
1777
+ manifest_message=manifest_message,
1778
+ )
1779
+ )
1780
+
1781
+ global _last_sdk_switch_issues, _last_configure_cache_issues
1782
+ _last_sdk_switch_issues = sdk_switch_issues
1783
+ _last_configure_cache_issues = configure_cache_issues
1784
+
1785
+ _write_manifest_after_dispatch(plan, build_root, [*outcomes, *held_outcomes], on_output, sdk_root)
1786
+ return outcomes
1787
+
1788
+
1789
+ def _write_manifest_after_dispatch(
1790
+ plan,
1791
+ build_root: Path,
1792
+ outcomes: list[SliceOutcome],
1793
+ on_output: Callable[[str], None],
1794
+ sdk_root: str | None,
1795
+ ) -> None:
1796
+ """The output seam: write the post-build `system-manifest.yaml` (the
1797
+ contract `tan run`/`flash`/`size`/`image` read) reflecting this run's
1798
+ per-slice status, and record the in-memory signals `tan run`/
1799
+ `tan build` decide from -- see [`last_manifest_write`] and
1800
+ [`last_manifest_write_failure`]. Always attempted, even for zero slices,
1801
+ mirroring the Rust oracle's `execute_slices_outcome` (which calls
1802
+ `write_post_build_manifest` unconditionally after its dispatch loop, not
1803
+ only when at least one slice ran). `outcomes` here is the FULL set
1804
+ overlaid onto the manifest -- dispatched slices plus any the caller
1805
+ held back (see [`execute_slices`]'s `held_outcomes` parameter) -- not
1806
+ necessarily what [`execute_slices`] itself returns."""
1807
+ results = [
1808
+ SliceRunResult(
1809
+ core_id=o.core_id,
1810
+ status=_WIRE_STATUS.get(o.status, "failed"),
1811
+ output_artefact=o.output_artefact,
1812
+ build_dir=o.build_dir,
1813
+ # tan-cli#510 review round 3, MAJOR: `manifest_message`, when set,
1814
+ # is the short form that must land on disk instead of `message`
1815
+ # (the missing-tool refusal's `-- searched PATH: <every entry>`
1816
+ # leaks the customer's machine layout into a persisted, forwarded
1817
+ # artefact -- see [`SliceOutcome.manifest_message`]).
1818
+ reason=o.manifest_message if o.manifest_message is not None else o.message,
1819
+ )
1820
+ for o in outcomes
1821
+ ]
1822
+ outcome = write_post_build_manifest(
1823
+ sdk_root=sdk_root,
1824
+ board_yaml=plan.board_yaml,
1825
+ base=str(build_root),
1826
+ plan_build_root=plan.build_root,
1827
+ results=results,
1828
+ plain_route=plain_route_target(plan),
1829
+ )
1830
+ global _last_manifest_write
1831
+ _last_manifest_write = _ManifestWriteSignal(
1832
+ manifest_written=outcome.write_failed_reason is None,
1833
+ native_sim_target=outcome.native_sim_target,
1834
+ write_failed_reason=outcome.write_failed_reason,
1835
+ )
1836
+ if outcome.write_failed_reason is not None:
1837
+ # Not silent -- mirrors the Rust oracle's identical `eprintln!` (via
1838
+ # `on_output`, this port's stderr-streaming callback: this function
1839
+ # has no envelope/issues list of its own to fold a warning `Issue`
1840
+ # into). `tan.commands.build_cmd._build` folds the SAME reason into
1841
+ # the JSON envelope's `issues` as `build.manifest-write-failed`
1842
+ # (`last_manifest_write_failure()`, see that module's own call
1843
+ # site) -- so the envelope-side half this docstring used to call
1844
+ # "out of this unit's scope" is covered too; this line is the
1845
+ # text-mode/stderr half only. Unlike the oracle (`execute/mod.rs`
1846
+ # gates its `eprintln!` on `text_mode`), this call fires in BOTH
1847
+ # `--format text` and `--format json` -- the same deliberate,
1848
+ # port-wide "stderr is always-on, never mode-gated" convention
1849
+ # `_maybe_pristine_stale_sdk_build_dir`'s docstring documents for
1850
+ # the sibling sdk-switch-pristine note; harmless since stdout, not
1851
+ # stderr, is the JSON envelope's only channel.
1852
+ on_output(f"note: skipped writing system-manifest.yaml — {outcome.write_failed_reason}")