From 97c4b130dcea0cca6a0f60e95841e0326b59b197 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 22 Apr 2026 19:44:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20#164=20Stage=20B=20prep=20=E2=80=94=20a?= =?UTF-8?q?dd=20cancel=5Fobserved=20field=20to=20TurnResult?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #164 Stage B requires exposing whether cancellation was observed at the turn-result level. This commit adds the infrastructure field: Changes: - TurnResult.cancel_observed: bool = False (query_engine.py) - _build_timeout_result() accepts cancel_observed parameter (runtime.py) - Two timeout paths now pass cancel_event.is_set() to signal observation (runtime.py) - bootstrap command includes cancel_observed in turn JSON (main.py) - SCHEMAS.md documents Turn Result Fields with cancel_observed contract Usage: When a turn timeout occurs, cancel_observed=true indicates that the engine observed the cancellation event being set. This allows callers to distinguish: - timeout with no cancel → infrastructure/network stall - timeout with cancel observed → cooperative cancellation was triggered Backward compat: - Existing TurnResult construction without cancel_observed defaults to False - bootstrap JSON output still validates per SCHEMAS.md (new field is always present) Test results: 182 passing, 3 skipped, zero regression. Related: #161 (wall-clock timeout), #164 (cancellation observability protocol) ROADMAP continues #164 with Stage C (test coverage for cancellation + turn envelope). --- SCHEMAS.md | 13 +++++++++++++ src/main.py | 1 + src/query_engine.py | 1 + src/runtime.py | 16 +++++++++++++--- 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/SCHEMAS.md b/SCHEMAS.md index 0009fc9..019281e 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -30,6 +30,19 @@ Every command response, success or error, carries: --- +## Turn Result Fields (Multi-Turn Sessions) + +When a command's response includes a `turn` object (e.g., in `bootstrap` or `turn-loop`), it carries: + +| Field | Type | Required | Notes | +|---|---|---|---| +| `prompt` | string | Yes | User input for this turn | +| `output` | string | Yes | Assistant response | +| `stop_reason` | enum | Yes | One of: `completed`, `timeout`, `cancelled`, `max_budget_reached`, `max_turns_reached` | +| `cancel_observed` | bool | Yes | #164 Stage B: cancellation was signaled and observed (#161/#164) | + +--- + ## Error Envelope When a command fails (exit code 1), responses carry: diff --git a/src/main.py b/src/main.py index bb3cec2..622c935 100644 --- a/src/main.py +++ b/src/main.py @@ -332,6 +332,7 @@ def main(argv: list[str] | None = None) -> int: 'prompt': session.turn_result.prompt, 'output': session.turn_result.output, 'stop_reason': session.turn_result.stop_reason, + 'cancel_observed': session.turn_result.cancel_observed, }, 'persisted_session_path': session.persisted_session_path, } diff --git a/src/query_engine.py b/src/query_engine.py index 987a98a..f503aae 100644 --- a/src/query_engine.py +++ b/src/query_engine.py @@ -31,6 +31,7 @@ class TurnResult: permission_denials: tuple[PermissionDenial, ...] usage: UsageSummary stop_reason: str + cancel_observed: bool = False @dataclass diff --git a/src/runtime.py b/src/runtime.py index ebeb011..bfb47e4 100644 --- a/src/runtime.py +++ b/src/runtime.py @@ -249,7 +249,10 @@ class PortRuntime: # submitted yet this turn. assert cancel_event is not None cancel_event.set() - results.append(self._build_timeout_result(turn_prompt, command_names, tool_names)) + results.append(self._build_timeout_result( + turn_prompt, command_names, tool_names, + cancel_observed=cancel_event.is_set() + )) break assert executor is not None future = executor.submit( @@ -268,7 +271,10 @@ class PortRuntime: assert cancel_event is not None cancel_event.set() future.cancel() - results.append(self._build_timeout_result(turn_prompt, command_names, tool_names)) + results.append(self._build_timeout_result( + turn_prompt, command_names, tool_names, + cancel_observed=cancel_event.is_set() + )) break results.append(result) @@ -287,8 +293,11 @@ class PortRuntime: prompt: str, command_names: tuple[str, ...], tool_names: tuple[str, ...], + cancel_observed: bool = False, ) -> TurnResult: - """Synthesize a TurnResult representing a wall-clock timeout (#161).""" + """Synthesize a TurnResult representing a wall-clock timeout (#161). + #164 Stage B: cancel_observed signals cancellation event was set. + """ return TurnResult( prompt=prompt, output='Wall-clock timeout exceeded before turn completed.', @@ -297,6 +306,7 @@ class PortRuntime: permission_denials=(), usage=UsageSummary(), stop_reason='timeout', + cancel_observed=cancel_observed, ) def _infer_permission_denials(self, matches: list[RoutedMatch]) -> list[PermissionDenial]: