Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
58 changes: 44 additions & 14 deletions apps/api/src/cora/api/_procedure_watcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,29 @@

`stalled_seconds = now - last_progress_at`.

`Running` and `Held` are clocked against SEPARATE operator-config windows
(`procedure_watcher_stale_after_seconds` and
`procedure_watcher_held_stale_after_seconds`), because the two statuses mean
different things: a `Running` procedure sitting idle for an hour is a plausible
stall, but a `Held` procedure sitting for an hour is routinely a deliberate
operator pause (a bakeout, waiting on beam, waiting on a collaborator) that can
legitimately run for days. One shared window would either false-flag an
ordinary overnight hold or blind the watcher to a genuinely stuck `Running`
conduct; this mirrors `_campaign_watcher`'s week-long default for its own
`Held` window.
Two operator-config windows (`procedure_watcher_stale_after_seconds` and
`procedure_watcher_held_stale_after_seconds`) are selected per procedure, and
what selects them is NOT the status. A `Running` procedure idle for an hour is
a plausible stall; a `Held` one is routinely a deliberate operator pause (a
bakeout, waiting on beam, waiting on a collaborator) that legitimately runs for
days, which is why the long window exists and mirrors `_campaign_watcher`'s.

But `Held` alone never meant "deliberate pause". The Conductor parks a conduct
on a recoverable step fault or a stood-down steering driver, and that is the
OPPOSITE case: nothing in CORA will move it until a person comes, so a week of
silence is precisely wrong for it. While a hold was one bit, the watcher could
not tell the two apart and gave every hold the benefit of the doubt. Now that a
hold records its cause, it does not have to.

So the long window applies only on POSITIVE evidence of a deliberate pause:
`hold_causes` is non-empty and every cause in it is `operator`. Everything else
gets the short one. That is deliberately the loud direction on missing or
unrecognized evidence, because the whole point is that a machine-parked conduct
must not hide, and being wrong costs one advisory Decision a person can ignore.
A hold placed before causes were recorded folds to `legacy-unscoped`, which is
not evidence of anything and so gets the short window; on a deployment carrying
such holds that is a one-time flag per procedure, which is the correct answer
to "nobody can say why this is held".

For `Held` the conduct is paused and accepts no activity, so
`last_status_changed_at` (the time it was held, on the list projection) is the
Expand Down Expand Up @@ -84,6 +97,7 @@
from cora.operation.adapters.postgres_procedure_activity_lookup import (
PostgresProcedureActivityLookup,
)
from cora.operation.aggregates.procedure import is_deliberate_pause
from cora.operation.errors import UnauthorizedError
from cora.operation.features.list_procedures import ListProcedures
from cora.operation.ports import InMemoryProcedureActivityLookup
Expand Down Expand Up @@ -136,6 +150,7 @@ async def _record_decision(
*,
procedure_id: UUID,
status: str,
hold_causes: tuple[str, ...],
last_progress_at: datetime,
now: datetime,
) -> None:
Expand All @@ -152,13 +167,17 @@ async def _record_decision(
entity_id=procedure_id,
now=now,
reasoning=(
f"Procedure has been {status} for {stalled_seconds}s without progressing "
"(past the staleness window, no recent activity); surfaced for operator "
"follow-up."
f"Procedure has been {status}{_held_by_clause(hold_causes)} for "
f"{stalled_seconds}s without progressing (past the staleness window, no "
"recent activity); surfaced for operator follow-up."
),
inputs={
"procedure_id": str(procedure_id),
"status": status,
# The causes ride the record because they are what chose the
# window: without them the Decision cannot be read back to see
# whether the short window was applied for the right reason.
"hold_causes": ",".join(hold_causes),
"last_progress_at": last_progress_at.isoformat(),
"stalled_seconds": str(stalled_seconds),
"occurred_at": now.isoformat(),
Expand Down Expand Up @@ -223,7 +242,10 @@ async def _watch_tick(
if base is None:
# No status-change timestamp recorded: cannot evaluate; defer.
continue
stale_after = stale_after_held if item.status == _STATUS_HELD else stale_after_running
hold_causes = tuple(item.hold_causes)
stale_after = stale_after_running
if item.status == _STATUS_HELD and is_deliberate_pause(hold_causes):
stale_after = stale_after_held
if not is_stalled(base, now, stale_after):
# Fresh by its status's own window. For Running a later activity only
# makes it fresher, so skipping here cannot hide a stall.
Expand All @@ -244,6 +266,7 @@ async def _watch_tick(
deps,
procedure_id=item.procedure_id,
status=item.status,
hold_causes=hold_causes,
last_progress_at=last_progress_at,
now=now,
)
Expand Down Expand Up @@ -289,4 +312,11 @@ async def startup_probe() -> None:
yield


def _held_by_clause(hold_causes: tuple[str, ...]) -> str:
"""The ` (held by x, y)` fragment, empty when nothing is recorded."""
if not hold_causes:
return ""
return f" (held by {', '.join(hold_causes)})"


__all__ = ["is_stalled", "procedure_watcher_lifespan"]
15 changes: 9 additions & 6 deletions apps/api/src/cora/infrastructure/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,12 +529,15 @@ class Settings(BaseSettings):
# sit without progressing before it is flagged; live conduct is far
# shorter-lived than a clearance or calibration, so the default is an hour
# (off by default; an operator sets the real window on enable).
# `procedure_watcher_held_stale_after_seconds` is the separate window for a
# Held procedure: a hold is commonly a deliberate operator pause (a bakeout,
# waiting on beam, waiting on a collaborator) that legitimately runs far
# longer than an hour, and a Held conduct logs no activity to fold in as a
# second chance, so it needs its own, much longer default; matches
# `campaign_watcher_stale_after_seconds`'s Held precedent, a week.
# `procedure_watcher_held_stale_after_seconds` is the window for a DELIBERATE
# operator pause (a bakeout, waiting on beam, waiting on a collaborator),
# which legitimately runs far longer than an hour and logs no activity to
# fold in as a second chance; matches `campaign_watcher_stale_after_seconds`'s
# Held precedent, a week. It is selected by the hold's recorded CAUSE, not by
# the Held status: a conduct the Conductor parked on a fault will not move
# until a person comes, so it takes the shorter window above. Anything the
# record cannot positively show to be an operator pause takes the short one
# too.
procedure_watcher_enabled: bool = False
procedure_watcher_tick_seconds: float = 300.0
procedure_watcher_stale_after_seconds: float = 3600.0
Expand Down
10 changes: 10 additions & 0 deletions apps/api/src/cora/infrastructure/record_export/_dispositions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1432,11 +1432,20 @@
},
"ProcedureHeld": {
"actuation_kind": "drop:text",
"cause": "drop:text",
"claim_id": "token:uuid",
"decided_by_decision_id": "token:uuid",
"occurred_at": "keep:time",
"procedure_id": "token:uuid",
"reason": "drop:text",
},
"ProcedureHoldClaimReleased": {
"cause": "drop:text",
"claim_id": "token:uuid",
"decided_by_decision_id": "token:uuid",
"occurred_at": "keep:time",
"procedure_id": "token:uuid",
},
"ProcedureIterationEnded": {
"advice_latency_ms": "keep:number",
"advised_next_point": "drop:opaque",
Expand Down Expand Up @@ -1486,6 +1495,7 @@
"occurred_at": "keep:time",
"procedure_id": "token:uuid",
"re_establishment_boundary": "keep:number",
"released_claim_id": "token:uuid",
},
"ProcedureStarted": {
"beam_requirement": "keep:enum:BeamRequirement",
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/cora/infrastructure/schema_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ class SchemaCheck:
expected: str


EXPECTED_SCHEMA_VERSION: Final = "20260910222120"
EXPECTED_SCHEMA_VERSION: Final = "20260911193339"
"""The newest migration this build was written against.

Hand-maintained, and deliberately not derived at runtime: the image does
Expand Down
22 changes: 22 additions & 0 deletions apps/api/src/cora/operation/aggregates/procedure/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,20 @@
PostgresOutcomeStore,
)
from cora.operation.aggregates.procedure.events import (
ATTENTION_HOLD_CAUSES,
HOLD_CAUSE_DRIVER_STAND_DOWN,
HOLD_CAUSE_OPERATOR,
HOLD_CAUSE_STEP_FAULT,
HOLD_CAUSES,
LEGACY_CAUSE,
LEGACY_CLAIM_ID,
ProcedureAborted,
ProcedureActivitiesLogbookOpened,
ProcedureCompleted,
ProcedureDiagnosticLogbookOpened,
ProcedureEvent,
ProcedureHeld,
ProcedureHoldClaimReleased,
ProcedureIterationEnded,
ProcedureIterationStarted,
ProcedureOutcomeLogbookOpened,
Expand All @@ -43,9 +51,11 @@
SteeringDesignRecorded,
event_type_name,
from_stored,
is_deliberate_pause,
to_payload,
)
from cora.operation.aggregates.procedure.evolver import evolve, fold
from cora.operation.aggregates.procedure.hold_claims import derive_claim_id
from cora.operation.aggregates.procedure.read import (
load_procedure,
load_procedure_with_events,
Expand Down Expand Up @@ -87,6 +97,7 @@
ProcedureCannotTruncateError,
ProcedureCapabilityExecutorMismatchError,
ProcedureEnclosureCoverageMismatchError,
ProcedureHoldClaimsRemainError,
ProcedureHoldReason,
ProcedureIterationLimitReachedError,
ProcedureName,
Expand All @@ -113,7 +124,14 @@
)

__all__ = [
"ATTENTION_HOLD_CAUSES",
"DIAGNOSTIC_LOGBOOK_SCHEMA",
"HOLD_CAUSES",
"HOLD_CAUSE_DRIVER_STAND_DOWN",
"HOLD_CAUSE_OPERATOR",
"HOLD_CAUSE_STEP_FAULT",
"LEGACY_CAUSE",
"LEGACY_CLAIM_ID",
"LOGBOOK_KIND_ACTIVITY",
"LOGBOOK_KIND_DIAGNOSTIC",
"LOGBOOK_KIND_OUTCOME",
Expand Down Expand Up @@ -167,6 +185,8 @@
"ProcedureEnclosureCoverageMismatchError",
"ProcedureEvent",
"ProcedureHeld",
"ProcedureHoldClaimReleased",
"ProcedureHoldClaimsRemainError",
"ProcedureHoldReason",
"ProcedureIterationEnded",
"ProcedureIterationLimitReachedError",
Expand Down Expand Up @@ -199,10 +219,12 @@
"ResolvedStepsRecorded",
"SteeringDesignRecorded",
"StepKind",
"derive_claim_id",
"event_type_name",
"evolve",
"fold",
"from_stored",
"is_deliberate_pause",
"load_procedure",
"load_procedure_with_events",
"merge_actuation_kinds",
Expand Down
Loading
Loading