Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f4ebcf8
WIP B6b: B6's root-cause fixes on current main, without formatting-on…
trissim Oct 12, 2026
e0ff775
WIP B6b: keep main's debug-boundary guard; registry takes B6's module…
trissim Oct 12, 2026
7947ca0
WIP B6b: smoke test list
trissim Oct 12, 2026
7d399d8
Merge remote-tracking branch 'origin/main' into refactor/B6b-ci-green
trissim Oct 12, 2026
6c74ffa
WIP B6b: stopped batch ends cancelled; GUI smoke reports the status n…
trissim Oct 12, 2026
b59cc8e
B6c: undo formatting-only hunks carried from B6
trissim Oct 12, 2026
38e929a
B6c: restore blank lines moved by the dechurn
trissim Oct 12, 2026
0cc7df7
B6c: knowledge-base test keeps main's text (its edit followed dropped…
trissim Oct 12, 2026
0812229
B6c: keep the structural ratchet without moving existing code
trissim Oct 12, 2026
5c407f2
B6c: passthrough qualifiers read from local records
trissim Oct 12, 2026
dc5a7aa
Merge remote-tracking branch 'origin/main' into fix/B6c-remaining-fixes
trissim Oct 12, 2026
7791d8a
Merge origin/main into B6d (U4, K1, CI triggers landed)
trissim Oct 12, 2026
b5099d7
B6d: main window keeps its session-event dispatcher
trissim Oct 12, 2026
416acfc
B6d: provenance test names K1's plane-axis class
trissim Oct 12, 2026
2a9dce1
B6d: receipt for the carried fixes; B6 and B6b receipts dropped
trissim Oct 12, 2026
afab85f
B6d: GUI smoke gives the bridge connection its transport config
trissim Oct 12, 2026
ffc7e2d
B6d: receipt states only what was observed
trissim Oct 12, 2026
cccda27
Merge origin/main into B6d (R1 landed)
trissim Oct 12, 2026
02d9ac5
Merge origin/main into B6 remaining fixes (U6 landed)
trissim Oct 12, 2026
73a6bf3
Merge origin/main into B6 remaining fixes (E2 landed)
trissim Oct 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Tracked files are checked out as their committed bytes on every platform.
# Benchmark corpora, reference-export sidecars and documentation audits are
# compared by sha256, which Windows end-of-line conversion would change.
* -text
# Windows command scripts keep the platform's line endings.
*.cmd text
*.bat text
13 changes: 5 additions & 8 deletions benchmark/adapters/openhcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from pathlib import Path
from typing import Any

from objectstate import replace_raw

from benchmark.adapters.cppipe_source import (
CPPipeSourceRequest,
CPPipeSourceResolution,
Expand Down Expand Up @@ -308,7 +310,6 @@ def _execution_global_config(
request: OpenHCSRunRequest,
*,
output_suffix: str,
output_dataset_root: Path,
compilation_debug_config: CompilationDebugConfig,
) -> GlobalPipelineConfig:
"""Select benchmark output paths without replacing declared path policy."""
Expand All @@ -330,7 +331,6 @@ def _execution_global_config(
),
compilation_debug_config=compilation_debug_config,
materialize_runtime_artifacts=request.materialize_runtime_artifacts,
materialization_results_path=output_dataset_root / "results",
)

def _run_converted_cppipe_pipeline(
Expand Down Expand Up @@ -361,9 +361,6 @@ def _run_converted_cppipe_pipeline(
) from exc

output_suffix = f"_{request.pipeline_name}_converted_cppipe"
output_dataset_root = (
request.output_dir / f"{request.dataset_path.name}{output_suffix}"
)
generated_source_path = request.output_dir / f"{cppipe_path.stem}_openhcs.py"
source_workspace_path = (
request.output_dir
Expand Down Expand Up @@ -417,7 +414,9 @@ def _run_converted_cppipe_pipeline(
"UI/ZMQ FunctionStep source before execution."
)
if compilation_debug_config.enabled:
pipeline_config = replace(
# replace_raw keeps the imported config's unset fields inheriting
# from the global config; dataclasses.replace would freeze them.
pipeline_config = replace_raw(
pipeline_config,
compilation_debug_config=LazyCompilationDebugConfig(
enabled=compilation_debug_config.enabled,
Expand All @@ -430,7 +429,6 @@ def _run_converted_cppipe_pipeline(
global_config = self._execution_global_config(
request,
output_suffix=output_suffix,
output_dataset_root=output_dataset_root,
compilation_debug_config=compilation_debug_config,
)
ensure_global_config_context(GlobalPipelineConfig, global_config)
Expand Down Expand Up @@ -550,7 +548,6 @@ def _run_converted_cppipe_pipeline(
raise ToolExecutionError(equivalence_failure_message)

metric_results = self._metric_results(request.metrics)
output_dataset_root.mkdir(parents=True, exist_ok=True)
execution_output_root.mkdir(parents=True, exist_ok=True)

provenance = {
Expand Down
5 changes: 5 additions & 0 deletions benchmark/cellprofiler_benchmark_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import json
import math
import os
import signal
import sys
from abc import ABC, abstractmethod
from collections.abc import Sequence
Expand Down Expand Up @@ -1069,6 +1070,10 @@ def create_benchmark_argument_parser() -> argparse.ArgumentParser:
def main(argv: Sequence[str] | None = None) -> int:
"""Run the registered benchmark CLI."""

if sys.platform == "win32":
# Ctrl+Break is the Windows console interrupt a launcher can deliver
# to this process alone; it stops a run exactly as Ctrl+C does.
signal.signal(signal.SIGBREAK, signal.default_int_handler)
arguments = tuple(sys.argv[1:] if argv is None else argv)
parser = create_benchmark_argument_parser()
parsed = parser.parse_args(arguments)
Expand Down
14 changes: 9 additions & 5 deletions benchmark/openhcs_measured_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@
import sys
import time
from collections.abc import Callable, Mapping
from dataclasses import (
dataclass,
field,
)
from dataclasses import dataclass
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -134,7 +131,12 @@ class _ZMQProgressTimingObserver:
compile_completed_at: float | None = None
execution_started_at: float | None = None
execution_completed_at: float | None = None
last_progress_monotonic: float = field(default_factory=time.monotonic)
last_progress_monotonic: float | None = None
"""Monotonic time of the latest progress; None until a run is submitted.

Starting the execution server is not execution: its client owns the
startup readiness timeout, so silence is measured from begin_run.
"""
last_progress_phase: str = ""
last_progress_status: str = ""
on_event: Callable[[Mapping[str, Any]], None] | None = None
Expand Down Expand Up @@ -217,6 +219,8 @@ def _duration(started_at: float | None, ended_at: float | None) -> float | None:

def inactivity_seconds(self, *, observed_at: float | None = None) -> float:
"""Return elapsed monotonic time since the latest server progress event."""
if self.last_progress_monotonic is None:
return 0.0
current = time.monotonic() if observed_at is None else observed_at
return max(0.0, current - self.last_progress_monotonic)

Expand Down
37 changes: 37 additions & 0 deletions docs/refactor/consolidation-20261009/B6d-remaining-fixes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# B6d: the fixes from B6 that main still needs

B6 (#1195) repaired main's CI in one branch that also reformatted 412 files and re-pinned the documentation audits. #1212 took its E1 boundary fix and #1214 its trigger restructuring. This is the rest: each fix below changes behaviour. Formatting commits, the ratchet mixin moves, the documentation re-pins, the workflow edits and the pyqt-reactive pin are not carried.

## Fixes

Tests named here fail on main `ae6ddace5` and pass on this branch unless marked otherwise.

| Fix | Cause | Evidence |
|---|---|---|
| Requestless UI-bridge operations pass the connection by keyword | the connection landed in the request slot | `test_capabilities.py::test_requestless_ui_bridge_capability_passes_connection_as_connection` |
| A batch stopped while compiling ends cancelled | the stop surfaced as an execution failure | `test_execution_control_stop_during_compile.py` |
| Metadata document targets follow the backend's declaration | a virtual backend (OMERO) got a local metadata document | `test_function_outputs.py::test_metadata_document_targets_follow_backend_declaration` |
| Catalog leaves out modules whose distributions the package markers exclude | Intel macOS excludes `openhcs-basicpy` but the catalog imported its module | `test_library_registry_discovery.py` (7 cases) |
| Cache preparation uses `os.cpu_count()` where there is no affinity API | macOS prepared the kernel cache serially | `test_preparation_admission.py::test_platform_without_affinity_admits_its_online_cpus` |
| Scalar components are reduced per pixel group | contributors were pooled across runtime planes, dropping `site` | `test_aggregate_source_scope.py::test_axis_varying_between_runtime_planes_is_kept_not_reduced` |
| The one-plane label projection belongs to every plane label mode | it lived only on `MatchImageStackLabels` | `test_singleton_rgb_label_invocation_477.py` (`SliceAlignedLabels` case) |
| Benchmark adapter keeps the imported config's inheritance and declared output policy | `dataclasses.replace` froze unset fields; a results path was forced | `test_openhcs_adapter.py::test_openhcs_adapter_preserves_declared_main_flow_output_policy` |
| Packaged knowledge recipes are placed by case name within a path budget | installed paths exceeded Windows MAX_PATH | `test_build_mcp_knowledge_assets.py` (does not import on main) |
| GUI smoke waits for the window's endpoint start, reports the status name, and gives the bridge connection its transport config | the catalog was read before the endpoint existed; the result held a class; U4 moved the connection spec to formbridge, where `transport_endpoint` takes the config | `test_smoke_installed_gui.py` (does not import on main); `smoke_local.sh` GUI step |
| Unit session creates its `QApplication` first; sibling test modules import through `tests` | an MCP transport created a `QCoreApplication`; bare imports fail under importlib mode | `test_ci_import_guards.py` guards the imports; it passes on main once the test files are fixed, so no production test fails on main |
| STEP_INPUT filters match stored source paths | they matched virtual workspace names | no touched local test fails on main; B6 found it in the backend-microscope CI jobs |
| Passthrough outputs keep their producer's filename qualifier | sibling surfaces merged into one file | same |
| BaSiC reads metadata through `ImagePayload.of` | `.metadata` was read off plain arrays | not shown failing on main locally; B6 found it in CI |
| Dataset rows carry the finished execution's error; demo reads `ViewerType.value` | the installed demo failed without a reason | installed demo job only |
| Measured-run silence is counted from `begin_run` | server startup time counted as execution silence | not isolated by a local test |
| `mahotas` is a core dependency | core catalog modules import it; it sat in an extra | packaging; python-314 job |
| MCP smoke sends `partition_values` | G2 renamed `wells` | wheel-integration job only |
| Windows: granularity accepts `'L'` uint32; custom-function sources are written as bytes; Ctrl+Break stops the CLI; dataset writability is probed by creating a file; `.gitattributes` keeps committed bytes; test process reaping skips process groups | each is a Windows-only difference | Windows jobs only; the POSIX paths are run by the touched tests |

## Ratchet

`OpenHCSMainWindow` and `PatternGroupExecutionRequest` are at their size budget. The window's four theme methods had no caller and are deleted (**unused**; guarded in `test_dead_ui_code_guards.py`). The passthrough helpers sit on `PatternGroupExecutionScope`.

## Done when

Touched tests pass, `smoke_local.sh` prints `smoke rc=0` with the GUI step, `guardrails_local.sh` ends `guardrails rc=0`, and the 30-workflow parity check passes.
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ Measured against `origin/main` (`89b8fdbff`, Python plus the MCP contract fixtur
Corrections to the plan above:

- **MCP tools are derived, not subclassed.** `SessionOperation` does not subclass `AgentCapabilityDeclaration` (that import cycles through the DTOs); `capabilities.py` derives one `openhcs_<operation_id>` capability per `HeadlessOperation`. The guard checks the derivation is total.
- **The headless journey runs a real execution server**, not a fake client; it and the GUI journey live in `tests/integration/test_session_journey.py`.
- **The headless journey runs a real execution server**, not a fake client; it lives in `tests/integration/test_session_journey.py`; the GUI journey, which needs the GUI extras and a Qt platform, lives in `tests/pyqt_gui/test_session_gui_journey.py`.
- **The pipeline editor keeps `displayed_steps`**, a render projection of `PipelineStepsView`, never written back; the guard allows it.
- **500 ms polling advice remains on the UI bridge** (`recommended_poll_interval_ms`): bridge events belong to U2. The session's own event stream is push (`openhcs_session_events`).
- **The session package loads PyQt6 transitively** through `pyqt_reactive.protocols`; the guard checks direct imports. Owner: U4 (library moves).
Expand Down
4 changes: 3 additions & 1 deletion openhcs/agent/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -1657,7 +1657,9 @@ class UiBridgeOperationExecution(AgentCapabilityInvocation):

def execute(self, context: object, *arguments: object) -> object:
*request, connection = arguments
return context.ui_bridge_service.invoke(self.operation, *request, connection)
return context.ui_bridge_service.invoke(
self.operation, *request, connection=connection
)


@dataclass(frozen=True, slots=True)
Expand Down
2 changes: 2 additions & 0 deletions openhcs/agent/dto/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ class DatasetRowState:
debug_session_id: str | None = None
finished_execution_id: str | None = None
"""The dataset's latest finished ordinary execution (its evidence id)."""
finished_execution_error: str | None = None
"""The server's error for that execution, when it did not complete."""
scope_accent_color: str | None = None

@property
Expand Down
33 changes: 20 additions & 13 deletions openhcs/agent/knowledge_manifest_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import os
import re
from collections.abc import Mapping
from dataclasses import dataclass
from enum import Enum
Expand All @@ -13,6 +14,12 @@
"docs/source/development/mcp_knowledge_base_manifest.json"
)
PACKAGED_KNOWLEDGE_BASE_ROOT = Path("resources/knowledge")
# Windows without long-path support rejects installed paths of 260 characters
# or more. The installer places the knowledge root at
# <install root>/env-XXXXXXXX/Lib/site-packages/openhcs/agent/resources/knowledge,
# so every resource path relative to that root must stay within this budget.
PACKAGED_KNOWLEDGE_PATH_BUDGET = 120
_PORTABLE_CASE_NAME = re.compile(r"[A-Za-z0-9_](?:[A-Za-z0-9_ .-]*[A-Za-z0-9_])?")


class KnowledgeBaseManifestField(str, Enum):
Expand Down Expand Up @@ -145,21 +152,21 @@ def load(cls, path: Path, *, source_root: Path) -> ComparisonManifestSnapshot:
return manifest

def native_source_relative_path(self, case: Mapping[str, object]) -> Path:
"""Derive one placement from its original manifest/root/path identity."""
"""Place one native recipe by its manifest-unique case name.

Upstream root/path layouts are deep; keying by case name keeps every
installed resource within ``PACKAGED_KNOWLEDGE_PATH_BUDGET``.
"""
path = Path(str(case["cppipe_path"]))
root = Path(str(case.get("cppipe_path_root", "unrooted")))
if (
path.is_absolute()
or root.is_absolute()
or ".." in path.parts
or ".." in root.parts
or path.suffix != ".cppipe"
):
name = str(case.get("name"))
if path.is_absolute() or ".." in path.parts or path.suffix != ".cppipe":
raise ValueError("Native knowledge source must be a relative .cppipe path.")
return (
self.path.relative_to(self.source_root).with_suffix(".sources")
/ root
/ path
if not _PORTABLE_CASE_NAME.fullmatch(name):
raise ValueError(
f"Native knowledge case name {name!r} is not a portable file name."
)
return self.path.relative_to(self.source_root).with_suffix(".sources") / (
f"{name}.cppipe"
)

def native_source_path(self, case: Mapping[str, object]) -> Path:
Expand Down
11 changes: 8 additions & 3 deletions openhcs/authoring/session/execution_control.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,14 @@ def check_all_completed(self) -> None:

async def handle_execution_failure(self) -> None:
session = self._session
for scope_id in tuple(session.batch.active_plates):
session.batch.mark_terminal(scope_id, ExecutionFailed)
session.set_dataset_state(scope_id, OrchestratorExecFailed)
if session.execution_state.stop_pending:
# The batch was stopped (for example while it was still compiling):
# the error is the stop arriving, so its datasets end cancelled.
self.cancel_active_datasets()
else:
for scope_id in tuple(session.batch.active_plates):
session.batch.mark_terminal(scope_id, ExecutionFailed)
session.set_dataset_state(scope_id, OrchestratorExecFailed)
session.execution_state = ManagerExecutionState.IDLE
try:
await session.client.disconnect()
Expand Down
22 changes: 21 additions & 1 deletion openhcs/authoring/session/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import os
import threading
import time
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass
from collections.abc import Callable, Iterable
Expand Down Expand Up @@ -204,6 +205,25 @@ def call(self, callback: Callable[[], ResultT]) -> ResultT:
return self._dispatcher.call(callback)


def _creates_files_in(directory: Path) -> bool:
"""Whether the filesystem lets this process create a file in ``directory``.

Permission bits (``os.access``) do not see Windows ACLs or read-only
shares; an actual creation does. One attempt answers: ``tempfile`` on
Windows reads a refused creation as a name collision whenever ``os.access``
passes and retries up to ``os.TMP_MAX`` (2**31 - 1) times. The probe file is
removed at once, so it leaves nothing behind.
"""

probe = Path(directory) / f".openhcs-write-probe-{uuid.uuid4().hex}"
try:
os.close(os.open(probe, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
except OSError:
return False
probe.unlink()
return True


class DatasetAccess(ABC):
"""Which dataset roots this session may read and initialize."""

Expand All @@ -218,7 +238,7 @@ def initializes_in_place(self, root: Path) -> bool:
"""

return all(
os.access(directory, os.W_OK)
_creates_files_in(directory)
for directory in {
Path(root),
*(path.parent for path in METADATA_CONFIG.managed_paths(root)),
Expand Down
22 changes: 13 additions & 9 deletions openhcs/authoring/session/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,16 @@ def row_state(
terminal = activity.terminal_status
runtime = activity.runtime
debug_phase = activity.debug_phase
finished_id, finished = next(
(
(execution_id, finished)
for execution_id, finished in reversed(
session.finished_executions.items()
)
if finished.scope_id == row.scope_id
),
(None, None),
)
return DatasetRowState(
scope_id=row.scope_id,
name=row.name,
Expand All @@ -305,15 +315,9 @@ def row_state(
source_root=relation.source_root,
debug_phase=None if debug_phase is None else debug_phase.name,
debug_session_id=activity.debug_session_id,
finished_execution_id=next(
(
execution_id
for execution_id, finished in reversed(
session.finished_executions.items()
)
if finished.scope_id == row.scope_id
),
None,
finished_execution_id=finished_id,
finished_execution_error=(
None if finished is None else finished.record.error
),
)

Expand Down
Loading
Loading