Skip to content

Commit f067762

Browse files
Cueclaude
authored andcommitted
fix(tracing): classify durable HITL pauses as suspended
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2c0378e commit f067762

10 files changed

Lines changed: 667 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **Durable HITL pauses now finalize traces as suspended rather than aborted.**
13+
`Agent.detach()` commits the pending request first, then the owning activation
14+
emits a terminal suspension event after runtime-state cleanup. Open agent,
15+
turn, chat, and tool spans end with `cubepi.run.outcome="suspended"`; observer
16+
failures cannot hide the terminal event, cancellation still propagates, and
17+
`respond()` opens a distinct correlated activation trace.
18+
1019
## [0.13.3] - 2026-08-02
1120

1221
### Fixed

cubepi/agent/agent.py

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@
5050

5151
TMessage = TypeVar("TMessage")
5252

53+
54+
def _consume_control_exception(future: asyncio.Future[StructuredValue]) -> None:
55+
"""Mark a detach control exception retrieved without changing awaiters."""
56+
if not future.cancelled():
57+
future.exception()
58+
59+
5360
if TYPE_CHECKING:
5461
from cubepi.deferred.types import DeferredStrategy, DeferredToolGroup
5562
from cubepi.providers.fallback import FallbackBoundModel
@@ -287,6 +294,7 @@ async def _composed_resolver(tool_call, *, context, signal=None):
287294
and hasattr(self.checkpointer, "mark_run_complete")
288295
)
289296
self._channel = channel
297+
self._pending_suspension_event: HitlRequest | None = None
290298
# _bind_emit is a _BaseChannel internal, not part of the HitlChannel
291299
# protocol. Third-party channels that only implement the public
292300
# protocol won't have it — skip the wiring instead of crashing.
@@ -384,6 +392,19 @@ async def _dispatch_outcome(self, outcome: RunOutcome | None, run_id: str) -> No
384392
cause=exc,
385393
) from exc
386394

395+
async def _emit_suspended_event(self, outcome: RunOutcome) -> None:
396+
"""Publish suspension only after the run transition is committed."""
397+
if outcome != "suspended":
398+
self._pending_suspension_event = None
399+
return
400+
pending = self._pending_suspension_event
401+
if pending is None:
402+
return
403+
self._pending_suspension_event = None
404+
from cubepi.agent.types import AgentSuspendedEvent
405+
406+
await self._process_event(AgentSuspendedEvent(pending_request=pending))
407+
387408
def _validate_hitl_bindings(self, run_id: str | None, *, caller: str) -> None:
388409
"""Reject HITL-bound tools/middleware that disagree with `run_id`.
389410
@@ -518,6 +539,7 @@ async def prompt(
518539
await self._run_prompt(messages)
519540
except BaseException:
520541
# Spec §3.7: leave active_run_id SET on failure.
542+
self._pending_suspension_event = None
521543
raise
522544
else:
523545
outcome: RunOutcome = self._state.last_outcome or "abandoned"
@@ -526,6 +548,7 @@ async def prompt(
526548
# the clear line below is unreachable on the exception path.
527549
await self._dispatch_outcome(outcome, effective_run_id)
528550
self._state.active_run_id = None
551+
await self._emit_suspended_event(outcome)
529552
return effective_run_id
530553

531554
async def fork(
@@ -723,11 +746,13 @@ async def resume(self, *, run_id: str | None = None) -> str:
723746
except BaseException:
724747
# Spec §3.7 parity: leave active_run_id SET on failure so callers
725748
# can observe which run failed.
749+
self._pending_suspension_event = None
726750
raise
727751
else:
728752
outcome: RunOutcome = self._state.last_outcome or "abandoned"
729753
await self._dispatch_outcome(outcome, effective_run_id)
730754
self._state.active_run_id = None
755+
await self._emit_suspended_event(outcome)
731756
return effective_run_id
732757

733758
def _build_stream_options(self, signal: asyncio.Event) -> StreamOptions:
@@ -803,8 +828,6 @@ def _create_context_snapshot(self) -> AgentContext:
803828
)
804829

805830
async def detach(self) -> None:
806-
from cubepi.agent.types import AgentSuspendedEvent
807-
808831
if self._channel is None:
809832
raise HitlError("agent has no channel bound")
810833
pending = self._channel.pending
@@ -814,11 +837,14 @@ async def detach(self) -> None:
814837
or self._channel._future.done()
815838
):
816839
return # nothing to detach
817-
# Emit the suspended event BEFORE triggering the exception, so listeners
818-
# see the real pending payload (codex pass 2 BLOCKING: previous draft
819-
# emitted from the loop with pending=None — fundamentally wrong).
820-
await self._process_event(AgentSuspendedEvent(pending_request=pending))
821-
self._channel._future.set_exception(HitlDetached())
840+
# Snapshot the payload before the channel clears its in-memory pending slot,
841+
# then commit the control-flow transition. The owning prompt/resume task
842+
# publishes AgentSuspendedEvent only after it records the suspended outcome
843+
# and clears active_run_id, so observers cannot report an uncommitted pause.
844+
self._pending_suspension_event = pending
845+
future = self._channel._future
846+
future.add_done_callback(_consume_control_exception)
847+
future.set_exception(HitlDetached())
822848

823849
async def load_pending_hitl_request(self) -> HitlRequest | None:
824850
if self.checkpointer is None or self.thread_id is None:
@@ -898,14 +924,16 @@ async def respond(
898924
await self._run_hitl_resume()
899925
except BaseException:
900926
# Spec §3.7: leave active_run_id SET on raise.
927+
self._pending_suspension_event = None
901928
raise
902929
else:
903930
# Legacy guard: pending persisted without run_id (older
904931
# save_pending_request callers) cannot drive dispatch.
932+
outcome: RunOutcome = self._state.last_outcome or "abandoned"
905933
if recovered_run_id is not None:
906-
outcome: RunOutcome = self._state.last_outcome or "abandoned"
907934
await self._dispatch_outcome(outcome, recovered_run_id)
908935
self._state.active_run_id = None
936+
await self._emit_suspended_event(outcome)
909937

910938
async def abort_pending(
911939
self, reason: str = "aborted by host"
@@ -1233,7 +1261,23 @@ async def _process_event(self, event: AgentEvent) -> None:
12331261
await self._emit_to_listeners(event)
12341262

12351263
async def _emit_to_listeners(self, event: AgentEvent) -> None:
1236-
for listener in self._listeners:
1237-
result = listener(event, self._active_signal)
1238-
if asyncio.iscoroutine(result):
1239-
await result
1264+
cancellation: asyncio.CancelledError | None = None
1265+
first_error: Exception | None = None
1266+
for listener in tuple(self._listeners):
1267+
try:
1268+
result = listener(event, self._active_signal)
1269+
if asyncio.iscoroutine(result):
1270+
await result
1271+
except asyncio.CancelledError as exc:
1272+
if cancellation is None:
1273+
cancellation = exc
1274+
except Exception as exc:
1275+
# One observer must not hide an event from later observers.
1276+
# Preserve existing error propagation after every listener had
1277+
# the chance to see the same committed state transition.
1278+
if first_error is None:
1279+
first_error = exc
1280+
if cancellation is not None:
1281+
raise cancellation
1282+
if first_error is not None:
1283+
raise first_error

cubepi/tracing/recorder.py

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
from cubepi.agent.types import (
3535
AgentEndEvent,
3636
AgentStartEvent,
37+
AgentSuspendedEvent,
3738
MessageEndEvent,
3839
MessageStartEvent,
3940
TurnEndEvent,
@@ -66,6 +67,7 @@
6667
CUBEPI_LLM_THINKING_LEVEL,
6768
CUBEPI_OUTPUT_MESSAGES_COUNT,
6869
CUBEPI_RUN_ID,
70+
CUBEPI_RUN_OUTCOME,
6971
CUBEPI_TOOL_BLOCK_REASON,
7072
CUBEPI_TOOL_BLOCKED_BY_HOOK,
7173
CUBEPI_TOOL_EXECUTION_MODE,
@@ -413,6 +415,8 @@ async def _on_agent_event(self, event: Any, signal: Any | None = None) -> None:
413415
self._on_message_end(event)
414416
elif isinstance(event, TurnEndEvent):
415417
self._on_turn_end(event)
418+
elif isinstance(event, AgentSuspendedEvent):
419+
self._on_agent_suspended()
416420
elif isinstance(event, AgentEndEvent):
417421
self._on_agent_end(event)
418422
# MessageUpdateEvent / ToolExecutionUpdateEvent: IGNORED.
@@ -679,6 +683,49 @@ def _on_agent_start(self) -> None:
679683
if history:
680684
self._run.transcript.extend(history)
681685

686+
def _on_agent_suspended(self) -> None:
687+
"""Finalize a durable HITL pause without classifying it as abort."""
688+
run = self._run
689+
if run is None:
690+
return
691+
for span in list(run.tool_spans.values()):
692+
try:
693+
span.set_attribute(CUBEPI_RUN_OUTCOME, "suspended")
694+
span.end()
695+
except Exception:
696+
pass
697+
run.tool_spans.clear()
698+
if run.chat_span is not None:
699+
try:
700+
run.chat_span.set_attribute(CUBEPI_RUN_OUTCOME, "suspended")
701+
run.chat_span.end()
702+
except Exception:
703+
pass
704+
run.chat_span = None
705+
run.chat_open_ns = None
706+
run.chat_first_chunk_recorded = False
707+
if run.turn_span is not None:
708+
try:
709+
run.turn_span.set_attribute(CUBEPI_RUN_OUTCOME, "suspended")
710+
run.turn_span.end()
711+
except Exception:
712+
pass
713+
run.turn_span = None
714+
run.agent_span.set_attribute(CUBEPI_RUN_OUTCOME, "suspended")
715+
run.agent_span.set_attribute(
716+
CUBEPI_OUTPUT_MESSAGES_COUNT, len(run.output_messages)
717+
)
718+
run.agent_span.end()
719+
self._sweep_tool_span_tokens(run)
720+
if run.stream_file is not None:
721+
try:
722+
run.stream_file.close()
723+
except Exception:
724+
pass
725+
run.stream_file = None
726+
self._reset_active_run()
727+
self._run = None
728+
682729
def _on_agent_end(self, event: AgentEndEvent) -> None:
683730
run = self._run
684731
if run is None:
@@ -760,9 +807,8 @@ def _on_turn_end(self, event: TurnEndEvent) -> None:
760807
if run is None or run.turn_span is None:
761808
return
762809
msg = event.message
763-
# Track output messages: assistant + any tool_results from this turn.
764-
run.turn_output_messages.append(msg)
765-
run.output_messages.append(msg)
810+
# Assistant output is captured at MessageEnd so a HITL suspension before
811+
# TurnEnd still records it. Add only this turn's tool results here.
766812
for tr in getattr(event, "tool_results", []) or []:
767813
run.turn_output_messages.append(tr)
768814
run.output_messages.append(tr)
@@ -948,6 +994,8 @@ def _on_message_end(self, event: MessageEndEvent) -> None:
948994
msg = event.message
949995
if getattr(msg, "role", None) == "assistant":
950996
run.transcript.append(msg)
997+
run.turn_output_messages.append(msg)
998+
run.output_messages.append(msg)
951999

9521000
# ------------------------------------------------------------------
9531001
# Provider listeners — drive the chat span lifetime

cubepi/tracing/schema.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
CUBEPI_AGENT_SYSTEM_PROMPT_SHA256 = "cubepi.agent.system_prompt.sha256"
105105
CUBEPI_INPUT_MESSAGES_COUNT = "cubepi.input.messages.count"
106106
CUBEPI_OUTPUT_MESSAGES_COUNT = "cubepi.output.messages.count"
107+
CUBEPI_RUN_OUTCOME = "cubepi.run.outcome"
107108
CUBEPI_ABORTED = "cubepi.aborted"
108109

109110
# Turn span attributes (cubepi.turn span — no gen_ai.operation.name)
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# HITL Suspension Trace Semantics Implementation Plan
2+
3+
**Goal:** Export durable HITL pauses as committed `suspended` activations,
4+
never as aborted, while preserving cancellation behavior and tracing cleanup.
5+
6+
**Architecture:** Snapshot the pending request in `Agent.detach()`, commit the
7+
`HitlDetached` channel transition, and publish `AgentSuspendedEvent` from the
8+
owning run task after outcome dispatch and active-run cleanup. The recorder then
9+
finalizes the activation with explicit suspended attributes.
10+
11+
**Tech stack:** Python 3.13, asyncio, FauxProvider, CheckpointedChannel,
12+
OpenTelemetry SDK, pytest, Ruff, mypy, uv.
13+
14+
## Task 1: Pin the production-shaped regression
15+
16+
- Add `tests/tracing/test_hitl_suspension.py` using a real Agent, HITL channel,
17+
ask-user tool, Tracer, and in-memory exporter.
18+
- Record RED for the original false-abort behavior.
19+
- Add adversarial RED assertions for:
20+
- event observers seeing pre-commit runtime state;
21+
- cross-task `_active_run` retention;
22+
- assistant output count incorrectly remaining zero;
23+
- an earlier regular/cancelled listener hiding suspension from tracing;
24+
- detach followed by immediate owner-task cancellation retaining the pending
25+
payload and emitting an unhandled-future warning;
26+
- same-tick `reset()` deleting a committed suspension snapshot before the
27+
owner task can publish it.
28+
29+
## Task 2: Commit suspension at the owning task boundary
30+
31+
- Snapshot the pending request in `Agent.detach()` before the channel clears its
32+
in-memory slot.
33+
- Set `HitlDetached` without publishing the terminal event from the host task.
34+
- After `prompt()`, `resume()`, or `respond()` records `outcome=suspended`,
35+
performs outcome dispatch, and clears `active_run_id`, publish the event from
36+
that same owning task.
37+
- Fan out agent events across regular exceptions and `CancelledError`; after
38+
fan-out, give cancellation priority or re-raise the first regular exception.
39+
- Clear the pending snapshot on every failed owning activation; do not let
40+
`reset()` clear a snapshot before that activation publishes its terminal.
41+
- Mark the detach control exception retrieved via a future done callback without
42+
changing what channel awaiters receive.
43+
44+
## Task 3: Add explicit trace semantics
45+
46+
- Add the recorder-owned schema attribute `cubepi.run.outcome`.
47+
- Handle committed `AgentSuspendedEvent` in `Recorder`.
48+
- End open tool/chat/turn/root spans with outcome `suspended`.
49+
- Sweep tool-span registrations, close stream state, and clear the active run.
50+
- Move assistant output accumulation to `MessageEndEvent` so suspension before
51+
`TurnEndEvent` still records the partial output.
52+
- Do not change `_close_open_spans()` cancellation semantics.
53+
54+
## Task 4: Document the lifecycle contract
55+
56+
- Update tracing guidance to distinguish cancellation from durable suspension.
57+
- Update HITL reference/durable examples to describe post-commit event timing.
58+
- State that resume creates a new activation trace.
59+
60+
## Task 5: Verify
61+
62+
Run:
63+
64+
```bash
65+
uv run pytest tests/tracing/test_hitl_suspension.py -q
66+
uv run pytest tests/tracing tests/hitl -q
67+
uv run pytest tests/
68+
uv run ruff check cubepi/ tests/
69+
uv run ruff format --check cubepi/ tests/
70+
uv run mypy cubepi
71+
```
72+
73+
Inspect `git diff --check` and confirm the branch contains only the spec, plan,
74+
focused tests, lifecycle fix, recorder/schema fix, and user-facing docs.

0 commit comments

Comments
 (0)