Skip to content
Open
22 changes: 22 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Changelog

All notable user-facing changes to OpenScan3 firmware are documented here.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added

- Tasks can now be chained by making one task depend on another. Dependent
tasks wait for successful completion of their prerequisite.

### Changed

- Interrupted scan and focus-stacking tasks can be resumed after a restart.
Scan progress and completed focus-stacking batches are reused, and replacing
an old task preserves dependency chains.
- Clarified that the generic task endpoint is intended for experimental and
custom tasks. Scan, focus-stacking, and cloud-upload tasks must use their
project-specific endpoints.
11 changes: 8 additions & 3 deletions docs/TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,9 +273,14 @@ only once. No JSON entry or second list of task names is required.

### 4. Make the task available where it is needed

Every registered task can already be started through
`POST /tasks/{task_name}`. If another firmware feature needs to start it, add a
small function for that feature:
Experimental and custom tasks can be started through
`POST /tasks/{task_name}`. This generic endpoint only creates the task; it does
not persist references on domain objects such as scans or projects and does
not perform domain-specific validation.

Tasks owned by a user-facing workflow must be started through that workflow's
specialized endpoint. If another firmware feature needs to start a task, add a
small service function for that feature:

```python
from openscan_firmware.controllers.services.tasks.task_manager import (
Expand Down
8 changes: 6 additions & 2 deletions openscan_firmware/controllers/services/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,13 +508,15 @@ async def upload_project(
*,
project_manager: ProjectManager | None = None,
token: str | None = None,
depends_on: str | None = None,
):
"""Schedule an upload task for an existing project.

Args:
project_name: Name of the project directory to upload.
project_manager: Optional project manager to validate the project exists.
token: Optional cloud token override forwarded to the task.
depends_on: Optional ID of a task that must complete successfully first.

Returns:
Task: The TaskManager model describing the scheduled upload.
Expand All @@ -540,16 +542,18 @@ async def upload_project(
task.task_type == "cloud_upload_task"
and task.run_args
and task.run_args[0] == project_name
and task.status in {TaskStatus.PENDING, TaskStatus.RUNNING}
and task.status in {TaskStatus.PENDING, TaskStatus.RUNNING, TaskStatus.INTERRUPTED}
):
raise CloudServiceError(
"An upload for this project is already in progress. Wait for completion or cancel it."
)

task_kwargs = {"depends_on": depends_on} if depends_on is not None else {}
task = await task_manager.create_and_run_task(
"cloud_upload_task",
project_name,
token=token,
**task_kwargs,
)
return task

Expand Down Expand Up @@ -599,7 +603,7 @@ async def download_project(
task.task_type == "cloud_download_task"
and task.run_args
and task.run_args[0] == project_name
and task.status in {TaskStatus.PENDING, TaskStatus.RUNNING}
and task.status in {TaskStatus.PENDING, TaskStatus.RUNNING, TaskStatus.INTERRUPTED}
):
raise CloudServiceError(
"A download for this project is already in progress. Wait for completion or cancel it."
Expand Down
49 changes: 44 additions & 5 deletions openscan_firmware/controllers/services/focus_stacking.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,17 @@
}


async def start_focus_stacking(project_name: str, scan_index: int) -> Task:
async def start_focus_stacking(
project_name: str,
scan_index: int,
depends_on: str | None = None,
) -> Task:
"""Start a focus stacking task and persist the task reference on the scan.

Args:
project_name: Name of the project containing the scan.
scan_index: Index of the scan to process.
depends_on: Optional ID of a task that must complete successfully first.

Returns:
The Task representing the focus stacking job.
Expand All @@ -38,6 +43,7 @@ async def start_focus_stacking(project_name: str, scan_index: int) -> Task:
raise ValueError(f"Scan {scan_index} not found in project '{project_name}'")

existing = scan.stacking_task_status
replaced_task_id: str | None = None
if existing and existing.task_id:
existing_task = task_manager.get_task_info(existing.task_id)
if existing_task and existing_task.status in _ACTIVE_STATUSES:
Expand All @@ -50,12 +56,19 @@ async def start_focus_stacking(project_name: str, scan_index: int) -> Task:
)
return existing_task

replaced_task_id = existing.task_id

task_kwargs = {"depends_on": depends_on} if depends_on is not None else {}
task = await task_manager.create_and_run_task(
"focus_stacking_task",
project_name,
scan_index,
**task_kwargs,
)

if replaced_task_id:
await task_manager.replace_task(replaced_task_id, task.id)

scan.stacking_task_status = StackingTaskStatus(task_id=task.id, status=task.status)
await project_manager.save_scan_state(scan)
return task
Expand All @@ -82,7 +95,7 @@ async def pause_focus_stacking(project_name: str, scan_index: int) -> Optional[T


async def resume_focus_stacking(project_name: str, scan_index: int) -> Optional[Task]:
"""Resume a paused focus stacking task and update the scan state."""
"""Resume a paused or interrupted focus stacking task and update the scan state."""

task_manager = get_task_manager()
project_manager = get_project_manager()
Expand All @@ -91,11 +104,37 @@ async def resume_focus_stacking(project_name: str, scan_index: int) -> Optional[
if scan is None:
raise ValueError(f"Scan {scan_index} not found in project '{project_name}'")

if not scan.stacking_task_status or not scan.stacking_task_status.task_id:
logger.warning("Cannot resume focus stacking for scan %s: no paused task", scan_index)
stacking_status = scan.stacking_task_status
if not stacking_status:
logger.warning("Cannot resume focus stacking for scan %s: no task", scan_index)
return None

if not stacking_status.task_id:
if stacking_status.status == TaskStatus.INTERRUPTED:
logger.info(
"Starting interrupted focus stacking for project '%s', scan %s.",
project_name,
scan_index,
)
return await start_focus_stacking(project_name, scan_index)

logger.warning(
"Cannot resume focus stacking for scan %s: no task ID",
scan_index,
)
return None

task = await task_manager.resume_task(stacking_status.task_id)
if task is None:
if stacking_status.status == TaskStatus.INTERRUPTED:
logger.info(
"Recreating missing interrupted focus stacking task for project '%s', scan %s.",
project_name,
scan_index,
)
return await start_focus_stacking(project_name, scan_index)
return None

task = await task_manager.resume_task(scan.stacking_task_status.task_id)
scan.stacking_task_status.status = task.status
await project_manager.save_scan_state(scan)
return task
Expand Down
4 changes: 1 addition & 3 deletions openscan_firmware/controllers/services/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,15 +296,14 @@ def _reset_incomplete_scans(self, project: Project) -> None:
for scan in project.scans.values():
dirty = False

if scan.status in {TaskStatus.RUNNING, TaskStatus.PENDING}:
if scan.status in {TaskStatus.RUNNING, TaskStatus.PENDING, TaskStatus.PAUSED}:
logger.debug(
"Resetting scan %s for project %s from %s to interrupted",
scan.index,
project.name,
scan.status.value,
)
scan.status = TaskStatus.INTERRUPTED
scan.task_id = None
dirty = True

stacking_status = scan.stacking_task_status
Expand All @@ -316,7 +315,6 @@ def _reset_incomplete_scans(self, project: Project) -> None:
stacking_status.status.value if stacking_status.status else "unknown",
)
stacking_status.status = TaskStatus.INTERRUPTED
stacking_status.task_id = None
dirty = True

if dirty:
Expand Down
23 changes: 17 additions & 6 deletions openscan_firmware/controllers/services/scans.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ async def start_scan(
scan: Scan,
camera_controller: CameraController,
start_from_step: int = 0,
depends_on: str | None = None,
) -> Task:
"""
Creates and starts a new scan task with simplified arguments.
Expand All @@ -38,6 +39,7 @@ async def start_scan(
scan: The scan object to be executed.
camera_controller: The camera controller for validation.
start_from_step: The step to resume the scan from.
depends_on: Optional ID of a task that must complete successfully first.

Returns:
The created Task object.
Expand All @@ -50,6 +52,7 @@ async def start_scan(

# If the scan already has a task_id, check its status.
# This prevents creating a new task for a scan that is already running, paused, etc.
replaced_task_id: str | None = None
if scan.task_id:
existing_task = task_manager.get_task_info(scan.task_id)
restartable_statuses = {
Expand All @@ -74,19 +77,27 @@ async def start_scan(
scan.task_id,
start_from_step,
)

# Remove the stale terminal task so the TaskManager list reflects only the new run
await task_manager.delete_task(existing_task.id)
scan.task_id = None
replaced_task_id = existing_task.id
else:
# Keep the stale ID long enough to repoint any dependents after the
# replacement task has been created.
replaced_task_id = scan.task_id

task_name = "scan_task"
task_kwargs = {"depends_on": depends_on} if depends_on is not None else {}
task = await task_manager.create_and_run_task(
task_name,
scan, start_from_step
scan,
start_from_step,
**task_kwargs,
)

if replaced_task_id:
await task_manager.replace_task(replaced_task_id, task.id)

# Save the task_id in the scan object for future reference
scan.task_id = task.id
scan.status = task.status
await project_manager.save_scan_state(scan)
logger.info(f"Started scan {scan.index} for project '{scan.project_name}' with task_id {task.id}")

Expand Down Expand Up @@ -154,4 +165,4 @@ async def cancel_scan(scan: Scan) -> Optional[Task]:
scan.status = TaskStatus.CANCELLED
project_manager = get_project_manager()
await project_manager.save_scan_state(scan)
return await task_manager.cancel_task(scan.task_id)
return await task_manager.cancel_task(scan.task_id)
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,28 @@ async def run(self, project_name: str, scan_index: int) -> AsyncGenerator[TaskPr
total_batches = len(batches)
logger.info(f"Found {total_batches} focus stack batches to process")

# TaskManager persists the last yielded batch number. Capture it
# before emitting new progress so an interrupted task can skip
# batches whose output was already written before shutdown.
resume_from_batch = min(
max(int(self._task_model.progress.current), 0),
total_batches,
)
if resume_from_batch:
logger.info(
"Resuming focus stacking for project '%s', scan %s from batch %s/%s",
project_name,
scan_index,
resume_from_batch,
total_batches,
)

# Yield initial progress
yield TaskProgress(current=0, total=total_batches, message="Starting calibration...")
yield TaskProgress(
current=resume_from_batch,
total=total_batches,
message="Starting calibration...",
)

# Calibration phase (CPU-intensive, run in executor)
logger.info(f"Calibrating with {num_calibration_batches} batches...")
Expand All @@ -103,14 +123,28 @@ async def run(self, project_name: str, scan_index: int) -> AsyncGenerator[TaskPr
calibration_path.write_text(json.dumps(calibration_payload, indent=2), encoding="utf-8")
logger.info("Calibration complete")

yield TaskProgress(current=0, total=total_batches, message="Calibration complete, starting stacking...")
yield TaskProgress(
current=resume_from_batch,
total=total_batches,
message="Calibration complete, starting stacking...",
)

# Process all batches
output_paths = []

for idx, (position, image_paths) in enumerate(sorted(batches.items())):
await self.wait_for_pause()

output_path = output_dir / f"stacked_scan{scan_index:02d}_{position:03d}.jpg"
if idx < resume_from_batch and output_path.exists():
output_paths.append(str(output_path))
logger.debug(
"Skipping already completed stacking batch %s (position %s)",
idx + 1,
position,
)
continue

# Check for cancel
if self.is_cancelled():
logger.info("Focus stacking cancelled by user")
Expand Down Expand Up @@ -138,7 +172,6 @@ async def run(self, project_name: str, scan_index: int) -> AsyncGenerator[TaskPr
return

# Stack this batch (CPU-intensive, run in executor)
output_path = output_dir / f"stacked_scan{scan_index:02d}_{position:03d}.jpg"
await loop.run_in_executor(
None,
self._stack_batch,
Expand Down
Loading