Plugin lifecycle
Every plugin is called in the same fixed order once per job cycle:
init(config, ctx: WorkerContext)
└── for each step:
run_step(action, inputs, ctx: StepContext)
cleanup(ctx: WorkerContext | None)
Corvus always calls cleanup(), even when the job fails or is aborted. During worker shutdown, cleanup() may receive None.
Context objects
WorkerContext
Available in init() and cleanup(). Contains job-level information.
| Attribute | Type | Description |
|---|---|---|
job_id | str | Unique ID for this job slot, e.g. "job-0" |
station | StationContext | Station identity |
trigger | TriggerContext | What fired this job |
routing | RoutingContext | None | Active part routing at trigger time |
runtime | dict | Mutable bag for plugin-owned state; Corvus never reads this |
StepContext
Available in run_step(). Extends WorkerContext with step-specific fields.
| Attribute | Type | Description |
|---|---|---|
step_id | str | ID of the current step |
attempt | int | Retry attempt number, starts at 1 |
loop_iteration | int | None | Current loop iteration, or None if not in a loop |
StationContext
Describes the physical test station. Read-only.
| Attribute | Example |
|---|---|
station_id | "ST-01" |
station_name | "Amp Test Station 1" |
station_type | "amplifier_eol" |
location | "Line 3, Bay 7" |
TriggerContext
| Attribute | Values |
|---|---|
trigger_type | "manual_enter", "scanner_input", "python_event" |
data | Trigger payload — see table below |
| Trigger type | data keys |
|---|---|
manual_enter | (empty) |
scanner_input | serial — the scanned serial number |
python_event | Plugin-defined keys from the hardware event |
serial = ctx.trigger.data.get("serial", "")
RoutingContext
Snapshot of the routing selection active at trigger time.
| Attribute | Example |
|---|---|
entry_id | "AMP-100_full-test" |
part_number | "AMP-100" |
part_name | "Digital Audio Amplifier v1.0" |
revision | "Rev A" |
entry_id is stable — it never changes even if the description is updated. Use it for fixture mapping or routing-specific configuration.
# Branch behavior by part number
if ctx.routing and ctx.routing.part_number == "AMP-100":
self._gain_correction = 1.05
# Gate a step by revision
if ctx.routing and ctx.routing.revision == "Rev B":
self._apply_offset_cal()
Always guard with if ctx.routing before accessing fields — None is valid in
non-production contexts where no routing config is loaded.
Plugin skeleton
from plugin_base import BasePlugin
from context import StepContext, WorkerContext
class MyPlugin(BasePlugin):
plugin_id = "my_plugin"
def init(self, config: dict, ctx: WorkerContext) -> None:
self.logger.info(
"init job=%s part=%s serial=%s",
ctx.job_id,
ctx.routing.part_number if ctx.routing else "N/A",
ctx.trigger.data.get("serial", "N/A"),
)
# Open hardware connections, apply config
def run_step(self, action: str, inputs: dict, ctx: StepContext) -> Any:
self._assert_action(action, ["measure_voltage"])
if action == "measure_voltage":
value = self._hw.read_voltage(inputs.get("channel", 0))
# Return raw data only — never decide pass/fail here
return {"voltage": value, "unit": "V"}
def cleanup(self, ctx: WorkerContext | None) -> None:
# Release hardware, close connections
# Must not raise
self.logger.info("cleanup complete")
Output contract
run_step() returns raw measurement data. Corvus passes it to the validation engine — the plugin never decides pass or fail.
| Return type | Example |
|---|---|
dict | {"voltage": 3.29, "unit": "V"} |
list | [1.0, 2.0, 3.0] |
int / float | 42 |
bool | True |
str | "OK" |
None | Only for steps with no validation |
Every run_step return value is stored in the report under steps[n].raw_data. The result, validation, and reason fields are always computed by Corvus — the plugin does not control them.
Validation types and required return shapes
Each validation type reads a specific key from raw_data. The key name is configured in the sequence file.
Boolean
# sequence config: { "type": "boolean", "key": "state", "expected": true }
return {"state": True}
Numeric
Operators: >, <, >=, <=, ==, !=, range.
# sequence config: { "type": "numeric", "key": "voltage", "operator": "range", "min": 3.1, "max": 3.5 }
return {"voltage": 3.29, "unit": "V"} # extra keys are ignored
String
Modes: exact or regex.
# sequence config: { "type": "string", "key": "status", "mode": "exact", "expected": "OK" }
return {"status": "OK", "code": 0} # extra keys are ignored
Array
Two parallel arrays for waveform or frequency-response validation. Modes: strict, interpolate, key_points.
return {
"channel": "left",
"frequencies": [20, 100, 1000, 10000, 20000],
"amplitudes_db": [-0.2, -0.1, 0.0, -0.3, -0.5],
}
No validation
If the step's validation field is absent or empty, the step auto-passes. Raw data is still recorded for traceability.
Error handling
| Situation | What to do | Corvus behavior |
|---|---|---|
| Hardware not found | raise RuntimeError("...") | Step → ERROR, job continues per continue_on_fail |
| Device timeout | raise TimeoutError("...") | Same as above |
| Measurement out of spec | Do not raise — return the value | Corvus validates and records FAIL |
| Unsupported action | raise ValueError("...") | Step → ERROR |
| Unrecoverable hardware fault | raise with descriptive message | Step → ERROR, retry if configured |
Corvus catches all exceptions from run_step, records the step as ERROR, and includes the original traceback in the worker log.
init() return value is ignored. Raise from init() to fail job startup.
cleanup() return value is ignored. Must not raise — catch and log errors internally.
Strict rules
Return raw data only. Never embed pass/fail decisions in the plugin. Corvus owns all validation.
Raise only on hardware failure. An out-of-spec value is not an execution failure — just return the value.
Never write to stdout.
Stdout is reserved for the internal communication channel. Use self.logger.
cleanup() must not raise.
Catch and log cleanup failures internally.
runtime is ephemeral.
The runtime dict is plugin-owned memory that survives within a single job cycle. Corvus never reads or persists it.