From d16aecb08b9da0f9d0141e9a175a34fb4415506e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:55:27 -0700 Subject: [PATCH 01/11] fix(claude): settle a queued send the CLI withdrew from its own cancelled frame Claude reports each uuid-stamped command's lifecycle (queued, started, completed, cancelled). A send it withdraws from its queue gets `cancelled` before the interrupt or cancel_async_message answer, so a lost or failed answer no longer leaves that send pending: it settles as withdrawn, with the same reason and words as the receipt path. A command the CLI already started also ends `cancelled` when its turn is interrupted or fails, so `cancelled` after `started` is not a withdrawal; an echoed send has left the waiter lists and is never reached. Tests replay real 2.1.280 captures, scrubbed. --- ...claude-lifecycle-capture-auth-failed.jsonl | 12 + .../claude-lifecycle-capture-batch-lead.jsonl | 28 ++ ...laude-lifecycle-capture-cancel-async.jsonl | 19 + ...ude-lifecycle-capture-interrupt-lost.jsonl | 20 + .../claude/claude-command-lifecycle.test.ts | 378 ++++++++++++++++++ src/main/claude/claude-command-lifecycle.ts | 32 ++ .../claude/claude-replay-turn-resolution.ts | 8 +- .../claude/claude-structured-session-state.ts | 2 + 8 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl create mode 100644 src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl create mode 100644 src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl create mode 100644 src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl create mode 100644 src/main/claude/claude-command-lifecycle.test.ts create mode 100644 src/main/claude/claude-command-lifecycle.ts diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl new file mode 100644 index 000000000000..76062add5725 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl @@ -0,0 +1,12 @@ +{"at": 1, "kind": "meta", "scenario": "auth-failed", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 288, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "39446645-6ec0-4807-9e18-95ac04c13143", "text": "Reply with text only; do not use any tools. Reply with exactly: CONTROL-DONE"} +{"at": 302, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "b8e8b60c-1592-4f05-a21a-853f6a1a5040", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 302, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "queued", "uuid": "17b4b010-4615-4bf0-9b63-7936336cd7c4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 306, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "started", "uuid": "b1ed2141-c5a2-4c7e-8403-dbeb53d15be2", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 319, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_scrubbed01_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "61043fb2-ec0d-4652-9ab8-00483ac5f026", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 321, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ad41aad3-ab9d-4b3c-a9f2-8610044d9482"}} +{"at": 342, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Reply with exactly: CONTROL-DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "timestamp": "2026-09-29T10:33:13.793Z", "isReplay": true}} +{"at": 342, "kind": "frame", "frame": {"type": "assistant", "message": {"diagnostics": null, "id": "58f67b61-a560-4d83-b099-4f1b2e920de8", "container": null, "model": "", "role": "assistant", "stop_details": null, "stop_reason": "stop_sequence", "stop_sequence": "", "type": "message", "usage": {"output_tokens_details": null, "input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": null, "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": null, "iterations": null, "speed": null}, "content": [{"type": "text", "text": "Not logged in \u00b7 Please run /login"}], "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "6c5de4d1-e149-4201-b18b-b0021d4650f2", "timestamp": "2026-09-29T10:33:13.809Z", "error": "authentication_failed", "is_api_error_message": true, "user_message_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "user_message_uuids": ["39446645-6ec0-4807-9e18-95ac04c13143"]}} +{"at": 346, "kind": "frame", "frame": {"duration_api_ms": 0, "stop_reason": "stop_sequence", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0, "usage": {"output_tokens_details": {"thinking_tokens": 0}, "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "", "iterations": [], "speed": "standard"}, "modelUsage": {}, "permission_denials": [], "terminal_reason": "api_error", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": true, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "Not logged in \u00b7 Please run /login", "type": "result", "duration_ms": 36, "uuid": "9736f712-8292-41be-bd62-305633c7e152", "user_message_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "user_message_uuids": ["39446645-6ec0-4807-9e18-95ac04c13143"], "queued_turn_count": 0, "result_index": 0}} +{"at": 347, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "cancelled", "uuid": "64028711-21fb-4a47-99c9-8ae448e4c577", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 347, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "081649ae-6232-43d6-b31b-6d1da6167020", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl new file mode 100644 index 000000000000..59b0db148ea5 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl @@ -0,0 +1,28 @@ +{"at": 15, "kind": "meta", "scenario": "batch-lead", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 334, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "adafe449-4ded-495b-a4db-b82589e901e6", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "9d3d1c58-6d69-408a-ae73-73d5c9656e9f", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 376, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "adafe449-4ded-495b-a4db-b82589e901e6", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "exit_code": 0, "outcome": "success", "uuid": "30ed6f57-a819-4c93-b4d3-7d76838c1950", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 410, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."} +{"at": 413, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "2dfef2b1-c08e-4a66-94b9-c3bbbe8d6adb", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 413, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "queued", "uuid": "378fac6f-7ffb-417f-b9d3-4311c3d99d64", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 414, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "started", "uuid": "4bdc9a15-e613-42f3-a912-f6bcf8712999", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 474, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_scrubbed01_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "b3869f1d-27b1-488a-a8a1-bde1a52fbf84", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 477, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "1e0b56e7-60ab-430d-910f-06c0337fb209"}} +{"at": 1478, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "timestamp": "2026-09-29T10:36:14.355Z", "isReplay": true}} +{"at": 1478, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"} +{"at": 1479, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8", "state": "queued", "uuid": "9438512a-5380-48da-8ddf-6adffc28cfbb", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1479, "kind": "dispatch", "clientMessageId": "client-C", "sentUuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "text": "Reply with text only; do not use any tools. Reply with exactly: C-DONE"} +{"at": 1479, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "state": "queued", "uuid": "4f1b992a-26e8-43c5-af40-c3e5ef5707c8", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1479, "kind": "control", "request": {"subtype": "cancel_async_message", "message_uuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8"}} +{"at": 1479, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8", "state": "cancelled", "uuid": "f52f03d1-df02-432c-8bb6-c94062877e6f", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1479, "kind": "control-answer", "response": {"cancelled": true}} +{"at": 10848, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_scrubbed02", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 8, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "89d623dc-6a78-46fc-9123-ee06e2d61a6b", "timestamp": "2026-09-29T10:36:24.740Z", "request_id": "req_scrubbed01", "user_message_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "user_message_uuids": ["0e1f8935-c61a-480b-8877-72c10dd1adda"]}} +{"at": 10885, "kind": "frame", "frame": {"duration_api_ms": 11864, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.0398048, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "output_tokens": 1497, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [{"input_tokens": 2, "output_tokens": 1497, "cache_read_input_tokens": 23004, "cache_creation_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1211, "outputTokens": 1518, "cacheReadInputTokens": 23004, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.0398048, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred", "ttft_ms": 10419, "type": "result", "duration_ms": 10453, "uuid": "922da184-ba96-4a9e-a133-27dd7f45818f", "ttft_stream_ms": 1063, "time_to_request_ms": 71, "first_content_frame_ms": 1063, "user_message_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "user_message_uuids": ["0e1f8935-c61a-480b-8877-72c10dd1adda"], "request_sent_wall_ms": 1790678174393, "queued_turn_count": 0, "result_index": 0}} +{"at": 10886, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "completed", "uuid": "3df6d400-c72e-4521-92f4-cc286ab007b7", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10886, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "state": "started", "uuid": "e7087639-8080-4803-b8e4-638cc31888e5", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10904, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_scrubbed01_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "6fce32e9-e653-477f-b6b6-4d10267b2991", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 10904, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "e546544b-76be-4d78-9094-9a8329a6a756"}} +{"at": 12309, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Reply with exactly: C-DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "timestamp": "2026-09-29T10:36:24.795Z", "isReplay": true}} +{"at": 12310, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_scrubbed03", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "C-DONE"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 9027, "cache_read_input_tokens": 15502, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 9027}, "output_tokens": 7, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "63316a0d-face-40ab-b7c5-a2f3c7aef73b", "timestamp": "2026-09-29T10:36:26.216Z", "request_id": "req_scrubbed02", "user_message_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "user_message_uuids": ["19ec4a51-3c43-4197-9305-f7e6e5ae96c5"]}} +{"at": 12331, "kind": "frame", "frame": {"duration_api_ms": 13267, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.11527720000000001, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 9027, "cache_read_input_tokens": 15502, "output_tokens": 7, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 9027, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [{"input_tokens": 4, "output_tokens": 7, "cache_read_input_tokens": 15502, "cache_creation_input_tokens": 9027, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 9027}, "type": "message"}], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1215, "outputTokens": 1525, "cacheReadInputTokens": 38506, "cacheCreationInputTokens": 9027, "webSearchRequests": 0, "costUSD": 0.11527720000000001, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "C-DONE", "ttft_ms": 1424, "type": "result", "duration_ms": 1438, "uuid": "fb897d43-901b-4f47-a701-d4d5f4cf8562", "ttft_stream_ms": 1423, "time_to_request_ms": 23, "first_content_frame_ms": 1423, "user_message_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "user_message_uuids": ["19ec4a51-3c43-4197-9305-f7e6e5ae96c5"], "request_sent_wall_ms": 1790678184816, "queued_turn_count": 0, "result_index": 1}} +{"at": 12331, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "state": "completed", "uuid": "bccf83ae-642e-42f8-8a33-1448870fb137", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 12332, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "5277a376-53d0-4a5d-b6f6-7669baccb91f", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl new file mode 100644 index 000000000000..a133cf2523e9 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl @@ -0,0 +1,19 @@ +{"at": 0, "kind": "meta", "scenario": "cancel-async", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 207, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "6c03b0d7-8119-4015-bd43-9c83fa47119b", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "ce5037b8-9e84-4b39-a598-cc4cea7123b4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 225, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "6c03b0d7-8119-4015-bd43-9c83fa47119b", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "exit_code": 0, "outcome": "success", "uuid": "93e4cdab-4976-4646-a5ce-9544936bfe6e", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 256, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."} +{"at": 261, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "3746879b-3bb8-47f8-a371-9544bc5812a5", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 261, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "state": "queued", "uuid": "a75f748a-f917-4f8a-afea-28f022859753", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 261, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "state": "started", "uuid": "e204ce9a-ee8a-4b64-ba12-eff1d33c52d1", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 301, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_scrubbed01_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "dad169fc-bced-4e8f-a40f-f3ea0537c763", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 302, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "f33c5acc-1608-453c-86a0-2568002a67d0"}} +{"at": 1096, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "timestamp": "2026-09-29T10:35:47.486Z", "isReplay": true}} +{"at": 1097, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "1ebf4091-6507-4b8b-838b-93c95875eded", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"} +{"at": 1097, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "1ebf4091-6507-4b8b-838b-93c95875eded", "state": "queued", "uuid": "a021a2ec-59b0-4470-ab40-5c3af8b9dcb0", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1097, "kind": "control", "request": {"subtype": "cancel_async_message", "message_uuid": "1ebf4091-6507-4b8b-838b-93c95875eded"}} +{"at": 1098, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "1ebf4091-6507-4b8b-838b-93c95875eded", "state": "cancelled", "uuid": "75187135-edfc-4738-8890-3c5204d319f1", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 1098, "kind": "control-answer", "response": {"cancelled": true}} +{"at": 10491, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_scrubbed02", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "output_tokens": 8, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "16c7fded-8293-4480-a708-bae2361dc27c", "timestamp": "2026-09-29T10:35:57.675Z", "request_id": "req_scrubbed01", "user_message_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "user_message_uuids": ["f3cd976f-dc3f-4eca-8622-ebf4e5b1d679"]}} +{"at": 10532, "kind": "frame", "frame": {"duration_api_ms": 12016, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.0398048, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "output_tokens": 1497, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [{"input_tokens": 2, "output_tokens": 1497, "cache_read_input_tokens": 23004, "cache_creation_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1211, "outputTokens": 1518, "cacheReadInputTokens": 23004, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.0398048, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred", "ttft_ms": 10214, "type": "result", "duration_ms": 10247, "uuid": "7aa2d6f4-d1dd-4749-ae09-7b0235246b51", "ttft_stream_ms": 834, "time_to_request_ms": 47, "first_content_frame_ms": 834, "user_message_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "user_message_uuids": ["f3cd976f-dc3f-4eca-8622-ebf4e5b1d679"], "request_sent_wall_ms": 1790678147508, "queued_turn_count": 0, "result_index": 0}} +{"at": 10532, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "state": "completed", "uuid": "28426f29-739b-48dc-94e8-63e85e197192", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 10533, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "b8e0b928-4ded-4ba4-8a88-5a0c5517657a", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl new file mode 100644 index 000000000000..4242490be096 --- /dev/null +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl @@ -0,0 +1,20 @@ +{"at": 3, "kind": "meta", "scenario": "interrupt-lost", "providerSessionId": "00000000-0000-4000-8000-00000000c1a0", "note": "Recorded against Claude CLI 2.1.280 through the Agent SDK transport; scrubbed: session id, home paths, API request and message ids, initialize answer; stream_event frames dropped."} +{"at": 333, "kind": "frame", "frame": {"type": "system", "subtype": "hook_started", "hook_id": "1c957d67-f23b-4d51-969d-187cd2a57e59", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "uuid": "a8c39655-a598-42a7-943e-899b511643d9", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 362, "kind": "frame", "frame": {"type": "system", "subtype": "hook_response", "hook_id": "1c957d67-f23b-4d51-969d-187cd2a57e59", "hook_name": "SessionStart:startup", "hook_event": "SessionStart", "exit_code": 0, "outcome": "success", "uuid": "1bc17c32-c0d8-4c1d-ace0-03327bbcf13b", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 416, "kind": "dispatch", "clientMessageId": "client-A", "sentUuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."} +{"at": 421, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "ef60bf56-2954-4f1a-bfaf-502f4cf7e5f9", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 421, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "queued", "uuid": "8f4de258-0da7-4ff7-a972-24c5d2b9e593", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 422, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "started", "uuid": "e70550c4-18ff-4795-9ddf-9dd267e82b9e", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 488, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_scrubbed01_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "31b93145-d113-4141-adb1-2174b07845cd", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 490, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "38344e0c-d00c-40dd-9da2-6cd73d7ad800"}} +{"at": 2520, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "timestamp": "2026-09-29T10:35:25.741Z", "isReplay": true}} +{"at": 3136, "kind": "frame", "frame": {"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 50, "estimated_tokens_delta": 50, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "d15945e1-6168-458e-9b2d-49f427327af2", "user_message_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda"}} +{"at": 3137, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"} +{"at": 3137, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "state": "queued", "uuid": "f6865348-9b42-410c-96e8-5bf01e1d68de", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 3137, "kind": "control", "request": {"subtype": "interrupt", "cancel_queued": true}} +{"at": 3138, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0ca6fc96-94c3-4462-9524-a8341729ab91", "state": "cancelled", "uuid": "d4cf2dfc-2530-4fa6-9f47-efab0874177a", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 3138, "kind": "control-answer", "response": {"still_queued": [], "cancelled": ["0ca6fc96-94c3-4462-9524-a8341729ab91"]}} +{"at": 3139, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "[Request interrupted by user]"}]}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ce4fe796-391f-4acc-b37c-49b668682592", "timestamp": "2026-09-29T10:35:28.422Z"}} +{"at": 3149, "kind": "frame", "frame": {"duration_api_ms": 2176, "stop_reason": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.005256, "usage": {"output_tokens_details": {"thinking_tokens": 0}, "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "", "iterations": [], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1209, "outputTokens": 21, "cacheReadInputTokens": 0, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.005256, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "aborted_streaming", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": true, "num_turns": 2, "subtype": "error_during_execution", "errors": ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null"], "user_message_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "type": "result", "duration_ms": 2717, "uuid": "9551c994-3a32-4f9a-b205-be27ae648b93", "user_message_uuids": ["d0649b29-11d5-45e7-9034-6fb789e78eda"], "queued_turn_count": 0, "result_index": 0}} +{"at": 3150, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "cancelled", "uuid": "24526d3c-00cc-488a-b899-239756190772", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} +{"at": 3150, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "idle", "uuid": "5de7c47b-2d4f-4f66-b301-d5e4de20e787", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts new file mode 100644 index 000000000000..09a639702a7b --- /dev/null +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -0,0 +1,378 @@ +// Replays of real Claude CLI 2.1.280 sessions (`__fixtures__/claude-lifecycle-capture-*.jsonl`) +// in which the CLI withdrew a queued send, interrupted a turn, or failed one. The recorded +// frames are the script; at each recorded control request the test decides how Orca's side of +// it went — the answer arriving, lost, or failing — and the CLI's own frames from while that +// request was outstanding are delivered either way. + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' +import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' +import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' +import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' +import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' +import type { ClaudeLateDispatchOutcome } from './claude-structured-session-state' +import { + fakeClaude, + identityFor, + PROVIDER_SESSION_ID, + type FakeConnection +} from './claude-structured-session-test-support' + +type CapturedEvent = + | { kind: 'meta'; providerSessionId: string } + | { kind: 'frame'; frame: Record } + | { kind: 'dispatch'; clientMessageId: string; sentUuid: string; text: string } + | { kind: 'control'; request: Record } + | { kind: 'control-answer'; response: Record } + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function decodeCapturedEvent(value: unknown): CapturedEvent { + if (!isRecord(value)) { + throw new Error('capture line is not a recorded event') + } + if (value.kind === 'meta' && typeof value.providerSessionId === 'string') { + return { kind: 'meta', providerSessionId: value.providerSessionId } + } + if (value.kind === 'frame' && isRecord(value.frame)) { + return { kind: 'frame', frame: value.frame } + } + if ( + value.kind === 'dispatch' && + typeof value.clientMessageId === 'string' && + typeof value.sentUuid === 'string' && + typeof value.text === 'string' + ) { + return { + kind: 'dispatch', + clientMessageId: value.clientMessageId, + sentUuid: value.sentUuid, + text: value.text + } + } + if (value.kind === 'control' && isRecord(value.request)) { + return { kind: 'control', request: value.request } + } + if (value.kind === 'control-answer' && isRecord(value.response)) { + return { kind: 'control-answer', response: value.response } + } + throw new Error(`capture line has unknown kind: ${String(value.kind)}`) +} + +function loadCapture(name: string): CapturedEvent[] { + const path = join(__dirname, '__fixtures__', `claude-lifecycle-capture-${name}.jsonl`) + return readFileSync(path, 'utf8') + .trim() + .split('\n') + .map((line) => decodeCapturedEvent(JSON.parse(line))) +} + +type Settlement = { sessionId: string } & ClaudeLateDispatchOutcome + +type ControlPoint = { + request: Record + /** The CLI's answer as recorded, mapped to this replay's uuids. */ + answer: Record + /** Delivers the frames the CLI emitted while the request was outstanding. */ + deliverInFlight: () => void + adapter: ClaudeStructuredSessionAdapter + connection: FakeConnection + routes: ReturnType['routes'] + liveUuid: (clientMessageId: string) => string +} + +async function replayCapture( + name: string, + options: { + /** What Orca did at the recorded control request; by default nothing (the CLI acted alone). */ + atControl?: (point: ControlPoint) => Promise + /** Drop these from the capture's init frames, as an older CLI would not advertise them. */ + withoutCapabilities?: string[] + /** Leave out captured frames, to model a sequence the capture brackets. */ + omitFrame?: (frame: Record) => boolean + /** Stop after the control request settles; its tail answers the CLI's own control path. */ + stopAfterControl?: boolean + } = {} +) { + const capture = loadCapture(name) + const settlements: Settlement[] = [] + const turnStates = new Map() + // The capture supplies every frame, startup proof included. + const claude = fakeClaude({ initProof: 'none', replayUuid: null }) + const adapter = new ClaudeStructuredSessionAdapter({ + resolveLaunch: async () => ({ + pathToClaudeCodeExecutable: 'claude', + options: {}, + cwd: '/work/repo', + claudeConfigDir: '/accounts/claude', + providerSessionId: PROVIDER_SESSION_ID, + resumeLeafUuid: null, + resumesTranscript: false, + continuesChain: false + }), + openConnection: claude.openConnection, + readProcessStartTime: async () => 1, + now: () => 1_700_000_200_000, + persistHandle: async () => {}, + onDispatchSettledLate: (settlement) => settlements.push(settlement) + }) + await adapter.acquire({ + identity: identityFor(), + fence: 7, + spawnToken: 'spawn-9', + events: { + appendItem: (_identity, body: AgentJournalItemBody) => { + const turn = readAgentJournalTurn(body) + if (turn) { + turnStates.set(turn.turnId, turn.state) + } + }, + appendTombstone: () => {}, + publish: () => {} + } + }) + const connection = claude.connections[0]! + + // Captured uuids -> the uuids the live dispatches mint during this replay. + const uuidMap = new Map() + const capturedSessionId = capture.flatMap((event) => + event.kind === 'meta' ? [event.providerSessionId] : [] + )[0]! + const mapUuids = (value: Record): Record => { + let text = JSON.stringify(value).replaceAll(capturedSessionId, PROVIDER_SESSION_ID) + for (const [captured, live] of uuidMap) { + text = text.replaceAll(captured, live) + } + const mapped: unknown = JSON.parse(text) + if (!isRecord(mapped)) { + throw new Error('mapped frame is not a record') + } + return mapped + } + const deliver = (frame: Record): void => { + if (options.omitFrame?.(frame)) { + return + } + const mapped = mapUuids(frame) + if (Array.isArray(mapped.capabilities) && options.withoutCapabilities) { + mapped.capabilities = mapped.capabilities.filter( + (capability) => !options.withoutCapabilities!.includes(String(capability)) + ) + } + connection.handlers.onMessage?.(mapped) + } + const liveUuid = (clientMessageId: string): string => { + const dispatch = capture.find( + (event) => event.kind === 'dispatch' && event.clientMessageId === clientMessageId + ) + return dispatch?.kind === 'dispatch' ? (uuidMap.get(dispatch.sentUuid) ?? '') : '' + } + + let proofDelivered = false + for (let index = 0; index < capture.length; index++) { + const event = capture[index]! + if (event.kind === 'frame') { + deliver(event.frame) + proofDelivered = true + } else if (event.kind === 'dispatch') { + if (proofDelivered) { + await adapter.awaitStarted('session-1') + } + await expect( + adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: event.clientMessageId, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: event.text }] }, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + uuidMap.set(event.sentUuid, String(connection.sent.at(-1)!.uuid)) + } else if (event.kind === 'control') { + const answerAt = capture.findIndex( + (candidate, at) => at > index && candidate.kind === 'control-answer' + ) + if (answerAt === -1) { + throw new Error('capture recorded no answer for its control request') + } + const inFlight = capture.slice(index + 1, answerAt) + const answer = capture[answerAt] + let delivered = false + const deliverInFlight = (): void => { + if (!delivered) { + delivered = true + for (const frameEvent of inFlight) { + if (frameEvent.kind === 'frame') { + deliver(frameEvent.frame) + } + } + } + } + const atControl = options.atControl ?? (async (point) => point.deliverInFlight()) + await atControl({ + request: mapUuids(event.request), + answer: answer?.kind === 'control-answer' ? mapUuids(answer.response) : {}, + deliverInFlight, + adapter, + connection, + routes: claude.routes, + liveUuid + }) + deliverInFlight() + if (options.stopAfterControl) { + break + } + index = answerAt + } + } + const settlementsFor = (clientMessageId: string) => + settlements + .filter((settlement) => settlement.clientMessageId === clientMessageId) + .map(({ sessionId: _sessionId, clientMessageId: _id, ...outcome }) => outcome) + return { settlementsFor, turnStates, liveUuid, connection, adapter } +} + +const WITHDRAWN = { + state: 'rejected', + reason: DISPATCH_REJECTED_CANCELLED, + rejection: { kind: 'cancelled' } +} + +function acceptedAs(uuid: string) { + return { providerIdentity: { provider: 'claude', sessionId: PROVIDER_SESSION_ID, uuid } } +} + +/** Orca's Stop with no turn named, as the chat sends it; the interrupt answers per `answer`. */ +function stopWithInterruptAnswer(answer: 'recorded' | 'lost') { + return async (point: ControlPoint): Promise => { + point.routes.interrupt = () => { + point.deliverInFlight() + if (answer === 'lost') { + throw new ClaudeControlRequestError('interrupt', 'Control request timed out') + } + return point.answer + } + await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + } +} + +describe('a Stop whose interrupt cancelled a queued follow-up (cancel_queued)', () => { + it.each(['recorded', 'lost'] as const)( + 'withdraws the follow-up once when the interrupt answer is %s, and leaves the stopped turn’s message accepted', + async (answer) => { + const replay = await replayCapture('interrupt-lost', { + atControl: stopWithInterruptAnswer(answer) + }) + + expect(replay.connection.calls.filter((call) => call.subtype === 'interrupt')).toEqual([ + { subtype: 'interrupt', params: { cancelQueued: true } } + ]) + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + // The interrupted turn's own message was echoed first; its later `cancelled` changes nothing. + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + } + ) + + it('ignores a cancelled frame for a send it no longer holds, or never sent', async () => { + const replay = await replayCapture('interrupt-lost', { + atControl: stopWithInterruptAnswer('lost') + }) + for (const commandUuid of [ + replay.liveUuid('client-A'), + replay.liveUuid('client-B'), + '5a1b0c63-0000-4000-8000-000000000000' + ]) { + replay.connection.handlers.onMessage?.({ + type: 'command_lifecycle', + command_uuid: commandUuid, + state: 'cancelled', + uuid: `lifecycle-${commandUuid}`, + session_id: PROVIDER_SESSION_ID + }) + } + + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + }) +}) + +describe('a Stop that withdraws the follow-up one at a time (no cancel_queued)', () => { + it.each([ + [ + 'times out', + () => { + throw new ClaudeControlRequestError('cancel_async_message', 'Control request timed out') + } + ], + [ + 'errors', + () => { + throw new Error('Query closed before response received') + } + ], + ['answers false', () => false] + ] as const)( + 'withdraws it from the CLI’s cancelled frame when cancel_async_message %s', + async (_label, answer) => { + const replay = await replayCapture('cancel-async', { + withoutCapabilities: ['interrupt_cancel_queued_v1'], + stopAfterControl: true, + atControl: async (point) => { + const queued = point.liveUuid('client-B') + point.routes.interrupt = () => ({ still_queued: [queued] }) + point.routes.cancel_async_message = () => { + point.deliverInFlight() + return answer() + } + await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + } + }) + + expect(replay.connection.calls.map((call) => call.subtype)).toContain('cancel_async_message') + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + } + ) +}) + +describe('a queued send the CLI withdraws without Orca hearing why', () => { + it('settles the withdrawn send from its own cancelled frame', async () => { + const replay = await replayCapture('cancel-async') + + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + }) + + it('withdraws only the batch lead; the send behind it runs as its own turn and is accepted', async () => { + const replay = await replayCapture('batch-lead') + + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + expect(replay.settlementsFor('client-C')).toEqual([acceptedAs(replay.liveUuid('client-C'))]) + expect([...replay.turnStates.entries()]).toEqual([ + [replay.liveUuid('client-A'), 'completed'], + [replay.liveUuid('client-C'), 'completed'] + ]) + }) +}) + +describe('a send the CLI started, then cancelled', () => { + it('stays accepted when its turn failed after the echo', async () => { + const replay = await replayCapture('auth-failed') + + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + }) + + it('is not read as withdrawn when the cancelled frame came before any echo', async () => { + // The auth-failed capture with the turn's output removed: started, then cancelled. + const replay = await replayCapture('auth-failed', { + omitFrame: (frame) => + frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result' + }) + + expect(replay.settlementsFor('client-A')).toEqual([]) + }) +}) diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts new file mode 100644 index 000000000000..b22ad2185f26 --- /dev/null +++ b/src/main/claude/claude-command-lifecycle.ts @@ -0,0 +1,32 @@ +// What Claude's per-command `command_lifecycle` frames (msg_lifecycle_v1) settle. +// +// `cancelled` is not by itself a withdrawal: a command the CLI already started also ends +// `cancelled` when its turn is interrupted or fails (measured on 2.1.280). Only a command +// cancelled before it started was withdrawn. That frame lands ahead of the control answer, so +// it settles the send even when the interrupt or cancel_async_message answer is lost. + +import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' +import { readClaudeFrameString } from './claude-structured-init-proof' +import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' +import type { ClaudeSession } from './claude-structured-session-state' + +export function observeClaudeCommandLifecycle( + session: ClaudeSession, + message: Record, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const commandUuid = readClaudeFrameString(message, 'command_uuid') + // An echoed send has left both lists, so nothing here can reach a delivered message. + const waiter = [...session.dispatchWaiters, ...session.retiredDispatchWaiters].find( + (candidate) => candidate.sentUuid === commandUuid + ) + if (!waiter) { + return + } + const state = message.state + if (state === 'queued' || state === 'started') { + waiter.commandLifecycle = state + } else if (state === 'cancelled' && waiter.commandLifecycle !== 'started') { + settleCancelledClaudeDispatchWaiters(session, [waiter.sentUuid], onSettledLate) + } +} diff --git a/src/main/claude/claude-replay-turn-resolution.ts b/src/main/claude/claude-replay-turn-resolution.ts index d34d0efc206c..9db40feac49e 100644 --- a/src/main/claude/claude-replay-turn-resolution.ts +++ b/src/main/claude/claude-replay-turn-resolution.ts @@ -3,8 +3,10 @@ // A replay or result is joined to its waiter by the client uuid Claude echoes. // A send Claude FOLDS into the running request cycle is replayed mid-cycle with // the client uuid adopted: once that cycle has done work, that replay is a -// delivery receipt and opens no boundary. +// delivery receipt and opens no boundary. A `command_lifecycle` frame opens no turn; its +// `cancelled` can settle a send the CLI withdrew (`claude-command-lifecycle.ts`). +import { observeClaudeCommandLifecycle } from './claude-command-lifecycle' import { forgetRetiredWaiter } from './claude-structured-dispatch-waiters' import { claudeHasReplayContent, @@ -28,6 +30,10 @@ export function resolveClaudeReplayTurn( message: Record, onSettledLate?: ClaudeLateDispatchSettlement ): ClaudeReplayTurnOrigin | null { + if (message.type === 'command_lifecycle') { + observeClaudeCommandLifecycle(session, message, onSettledLate) + return null + } const envelope = readClaudeMessageEnvelope(message) const isUserReplay = envelope?.role === 'user' && diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 6848d9a19437..6a6b4a033311 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -139,6 +139,8 @@ export type ClaudeDispatchWaiter = { settledUuid?: string /** The write failed or the child died, but a replay may still name it. */ retired?: boolean + /** The CLI's last non-terminal `command_lifecycle` state for this send; in memory only. */ + commandLifecycle?: 'queued' | 'started' /** Bounded digest/summary for compatibility CLIs that mint UUIDs. */ replayContentKey: string } From 44612449b68e1aa9b8f59c7d809cf5136315ac7a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 03:56:15 -0700 Subject: [PATCH 02/11] fix(claude): release a doubted send when the CLI reports its session idle A Claude send whose write ended in doubt is recorded `unknown`, and a live `unknown` reads as work still owed, so the chat showed Working until the child exited. Claude sends `session_state_changed idle` only once its whole queue has drained, so it can no longer be holding that send. The runtime now routes that report to the host's existing release, the same one Codex's thread-stopped report uses; it retires `unknown` only, never `pending`. --- .../claude/claude-command-lifecycle.test.ts | 32 +++++- .../claude-structured-session-acquisition.ts | 5 + .../claude/claude-structured-session-state.ts | 2 + ...e-structured-idle-releases-unknown.test.ts | 108 ++++++++++++++++++ .../structured-agent-session-runtime.ts | 29 ++--- .../structured-claude-runtime-adapter.ts | 2 + 6 files changed, 163 insertions(+), 15 deletions(-) create mode 100644 src/main/runtime/claude-structured-idle-releases-unknown.test.ts diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 09a639702a7b..1f3befff71fa 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -100,6 +100,7 @@ async function replayCapture( ) { const capture = loadCapture(name) const settlements: Settlement[] = [] + const idles: string[] = [] const turnStates = new Map() // The capture supplies every frame, startup proof included. const claude = fakeClaude({ initProof: 'none', replayUuid: null }) @@ -118,7 +119,8 @@ async function replayCapture( readProcessStartTime: async () => 1, now: () => 1_700_000_200_000, persistHandle: async () => {}, - onDispatchSettledLate: (settlement) => settlements.push(settlement) + onDispatchSettledLate: (settlement) => settlements.push(settlement), + onSessionIdle: ({ sessionId }) => idles.push(sessionId) }) await adapter.acquire({ identity: identityFor(), @@ -232,7 +234,7 @@ async function replayCapture( settlements .filter((settlement) => settlement.clientMessageId === clientMessageId) .map(({ sessionId: _sessionId, clientMessageId: _id, ...outcome }) => outcome) - return { settlementsFor, turnStates, liveUuid, connection, adapter } + return { settlementsFor, idles, turnStates, liveUuid, connection, adapter } } const WITHDRAWN = { @@ -376,3 +378,29 @@ describe('a send the CLI started, then cancelled', () => { expect(replay.settlementsFor('client-A')).toEqual([]) }) }) + +describe('the CLI reporting its session idle', () => { + it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( + 'is reported once per idle frame (%s), never for running', + async (name) => { + const replay = await replayCapture(name) + + expect(replay.idles).toEqual(['session-1']) + } + ) + + it('is not reported from a child the session no longer holds', async () => { + const replay = await replayCapture('auth-failed') + await replay.adapter.closeSession('session-1') + + replay.connection.handlers.onMessage?.({ + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + uuid: 'late-idle', + session_id: PROVIDER_SESSION_ID + }) + + expect(replay.idles).toEqual(['session-1']) + }) +}) diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index cd8836e4b826..b4adbfccfa5c 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -8,6 +8,7 @@ import { isClaudeAuthSwitchInProgress } from '../claude-accounts/live-pty-gate' import { openClaudeStreamJsonConnection } from './claude-stream-json-connection' import { buildClaudePermissionCallbacks } from './claude-structured-inbound-control' import { resolveClaudeReplayTurn } from './claude-replay-turn-resolution' +import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' import { readClaudeCapabilities, readClaudeFrameString, @@ -117,6 +118,10 @@ export async function acquireClaudeSession({ if (message.type === 'result' && sessions.get(sessionId) === liveSession) { persistClaudeTurnResumePoint(sessionId, liveSession, deps) } + // The CLI idles only once its queue drains, so it holds none of this session's sends. + if (claudeSessionStateEndsTurn(message) && sessions.get(sessionId) === liveSession) { + deps.onSessionIdle?.({ sessionId }) + } } // Settled after the turn this echo opens is emitted: a send read as answered before its turn // lands reads as nothing running, and Stop and Working blink off in between. diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 6a6b4a033311..b2e1ea5bf1b1 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -95,6 +95,8 @@ export type ClaudeStructuredSessionAdapterDeps = { onEvent?: (event: ClaudeStructuredSessionEvent) => void /** Direct settlement path for provider-proven late dispatch outcomes. */ onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => void + /** The CLI reported `session_state_changed idle`, which it sends only once its queue drains. */ + onSessionIdle?: (input: { sessionId: string }) => void onBackgroundTasksChanged?: ( sessionId: string, state: AgentSessionBackgroundTaskState | null diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts new file mode 100644 index 000000000000..becdc9f2b389 --- /dev/null +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -0,0 +1,108 @@ +// A Claude send whose write ended in doubt is recorded `unknown`, and a live `unknown` reads as +// work still owed. The CLI reports `session_state_changed idle` only once its queue has drained, +// so that report retires the doubt; a send whose dispatch is still `pending` is left alone. +// Against the production runtime, adapter, record store and host, with only the CLI scripted. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' +import { DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' + +const SESSION = 'claude-idle-release' +const PROVIDER_SESSION = claudeSessionIdForOrcaSession(SESSION) +const CALLER = { callerKey: 'client-1' } + +let claude = createScriptedClaudeRuntime([SESSION]) +let operations = 0 + +afterEach(async () => { + await claude.dispose() + claude = createScriptedClaudeRuntime([SESSION]) +}) + +function fence(host: StructuredAgentSessionHost): number { + return host.deps.store.getRecord(SESSION)?.lease.runtimeFence ?? 0 +} + +async function send(host: StructuredAgentSessionHost, text: string): Promise { + const body = hostTestMessage(text) + const sent = await host.send(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: `${Date.now()}-${(++operations).toString(16).padStart(32, '0')}`, + expectedRuntimeFence: fence(host), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + }) + expect(sent, JSON.stringify(sent)).toMatchObject({ ok: true }) + return sent.ok ? sent.value.clientMessageId : '' +} + +async function submission(host: StructuredAgentSessionHost, clientMessageId: string) { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +function sessionState(state: 'running' | 'idle'): Record { + return { + type: 'system', + subtype: 'session_state_changed', + state, + uuid: `state-${state}`, + session_id: PROVIDER_SESSION + } +} + +describe('a live Claude chat whose CLI reports idle', () => { + it('releases a send whose write ended in doubt, and leaves a pending one alone', async () => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const child = claude.child(SESSION) + // Handed to the SDK, then the write's outcome was lost. + child.connection.send = async (_message, beforeDispatch) => { + await beforeDispatch?.() + throw new Error('stdin write stalled') + } + const doubted = await send(host, 'first') + await vi.waitFor(async () => + expect(await submission(host, doubted)).toMatchObject({ dispatchState: 'unknown' }) + ) + // Written, never echoed: its dispatch has returned, but nothing has settled it. + child.connection.send = async (_message, beforeDispatch) => { + await beforeDispatch?.() + } + const pending = await send(host, 'second') + await vi.waitFor(async () => + expect(await submission(host, pending)).toMatchObject({ dispatchState: 'pending' }) + ) + + child.handlers.onMessage?.(sessionState('running')) + await new Promise((resolve) => setTimeout(resolve, 50)) + // POSITIVE CONTROL: only idle releases it. + expect((await submission(host, doubted))?.recovered).toBeUndefined() + + child.handlers.onMessage?.(sessionState('idle')) + + await vi.waitFor(async () => + expect(await submission(host, doubted)).toMatchObject({ + dispatchState: 'unknown', + recovered: true, + // The write's own doubt is the sharper fact, so it survives the release. + reason: `${DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN}: stdin write stalled` + }) + ) + expect(await submission(host, pending)).toMatchObject({ dispatchState: 'pending' }) + expect((await submission(host, pending))?.recovered).toBeUndefined() + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 1659fcd512ea..10db8ba3bda4 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -215,6 +215,20 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + void host + ?.releaseUnansweredDispatches({ + sessionId, + reason: DISPATCH_DOUBT_PROVIDER_IDLE + }) + .catch((error) => + deps.onError?.({ + scope: `structured-agent-session-unanswered-dispatch:${sessionId}`, + error + }) + ) + } const codex = new CodexStructuredSessionAdapter({ resolveLaunch: createCodexStructuredLaunchResolver({ store, @@ -233,19 +247,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, - onPrimaryThreadStoppedRunning: ({ sessionId }) => { - void host - ?.releaseUnansweredDispatches({ - sessionId, - reason: DISPATCH_DOUBT_PROVIDER_IDLE - }) - .catch((error) => - deps.onError?.({ - scope: `structured-agent-session-unanswered-dispatch:${sessionId}`, - error - }) - ) - }, + onPrimaryThreadStoppedRunning: releaseUnansweredDispatches, onEvent: (event) => { if (event.type === 'ended' && 'cause' in event && event.cause === 'unexpected-exit') { lifecycle.deliver(event) @@ -274,6 +276,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise host?.publishChildWorkEvidence(sessionId, evidence), onDispatchSettledLate, + onSessionIdle: releaseUnansweredDispatches, ...(deps.openClaudeConnection ? { openClaudeConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), modelCatalog: agentModelCatalogStore diff --git a/src/main/runtime/structured-claude-runtime-adapter.ts b/src/main/runtime/structured-claude-runtime-adapter.ts index 4160b81c4dea..b2367e1aff09 100644 --- a/src/main/runtime/structured-claude-runtime-adapter.ts +++ b/src/main/runtime/structured-claude-runtime-adapter.ts @@ -40,6 +40,7 @@ export type StructuredClaudeRuntimeAdapterDeps = { state: AgentSessionBackgroundTaskState | null ) => void onDispatchSettledLate?: ClaudeStructuredSessionAdapterDeps['onDispatchSettledLate'] + onSessionIdle?: ClaudeStructuredSessionAdapterDeps['onSessionIdle'] onChildWorkEvidence?: ClaudeStructuredSessionAdapterDeps['onChildWorkEvidence'] } @@ -130,6 +131,7 @@ export function createStructuredClaudeRuntimeAdapter( ? { onBackgroundTasksChanged: deps.onBackgroundTasksChanged } : {}), ...(deps.onDispatchSettledLate ? { onDispatchSettledLate: deps.onDispatchSettledLate } : {}), + ...(deps.onSessionIdle ? { onSessionIdle: deps.onSessionIdle } : {}), ...(deps.onChildWorkEvidence ? { onChildWorkEvidence: deps.onChildWorkEvidence } : {}), ...(deps.openClaudeConnection ? { openConnection: deps.openClaudeConnection } : {}), ...(deps.readProcessStartTime ? { readProcessStartTime: deps.readProcessStartTime } : {}), From 45212a991007320bd51290c24c3ba94a49c7df0c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 04:09:48 -0700 Subject: [PATCH 03/11] fix(claude): keep a command's started mark when a redelivery re-emits queued; fixtures name msg_lifecycle_v1 --- .../claude-lifecycle-capture-auth-failed.jsonl | 2 +- .../claude-lifecycle-capture-batch-lead.jsonl | 4 ++-- ...claude-lifecycle-capture-cancel-async.jsonl | 2 +- ...aude-lifecycle-capture-interrupt-lost.jsonl | 2 +- .../claude/claude-command-lifecycle.test.ts | 18 ++++++++++++++++++ src/main/claude/claude-command-lifecycle.ts | 3 ++- 6 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl index 76062add5725..38dd3136218b 100644 --- a/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-auth-failed.jsonl @@ -3,7 +3,7 @@ {"at": 302, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "b8e8b60c-1592-4f05-a21a-853f6a1a5040", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} {"at": 302, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "queued", "uuid": "17b4b010-4615-4bf0-9b63-7936336cd7c4", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} {"at": 306, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "state": "started", "uuid": "b1ed2141-c5a2-4c7e-8403-dbeb53d15be2", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} -{"at": 319, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_scrubbed01_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "61043fb2-ec0d-4652-9ab8-00483ac5f026", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 319, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "61043fb2-ec0d-4652-9ab8-00483ac5f026", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} {"at": 321, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "ad41aad3-ab9d-4b3c-a9f2-8610044d9482"}} {"at": 342, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Reply with exactly: CONTROL-DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "timestamp": "2026-09-29T10:33:13.793Z", "isReplay": true}} {"at": 342, "kind": "frame", "frame": {"type": "assistant", "message": {"diagnostics": null, "id": "58f67b61-a560-4d83-b099-4f1b2e920de8", "container": null, "model": "", "role": "assistant", "stop_details": null, "stop_reason": "stop_sequence", "stop_sequence": "", "type": "message", "usage": {"output_tokens_details": null, "input_tokens": 0, "output_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": null, "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": null, "iterations": null, "speed": null}, "content": [{"type": "text", "text": "Not logged in \u00b7 Please run /login"}], "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "6c5de4d1-e149-4201-b18b-b0021d4650f2", "timestamp": "2026-09-29T10:33:13.809Z", "error": "authentication_failed", "is_api_error_message": true, "user_message_uuid": "39446645-6ec0-4807-9e18-95ac04c13143", "user_message_uuids": ["39446645-6ec0-4807-9e18-95ac04c13143"]}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl index 59b0db148ea5..50cab7abe664 100644 --- a/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-batch-lead.jsonl @@ -5,7 +5,7 @@ {"at": 413, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "2dfef2b1-c08e-4a66-94b9-c3bbbe8d6adb", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} {"at": 413, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "queued", "uuid": "378fac6f-7ffb-417f-b9d3-4311c3d99d64", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} {"at": 414, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "started", "uuid": "4bdc9a15-e613-42f3-a912-f6bcf8712999", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} -{"at": 474, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_scrubbed01_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "b3869f1d-27b1-488a-a8a1-bde1a52fbf84", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 474, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "b3869f1d-27b1-488a-a8a1-bde1a52fbf84", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} {"at": 477, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "1e0b56e7-60ab-430d-910f-06c0337fb209"}} {"at": 1478, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "timestamp": "2026-09-29T10:36:14.355Z", "isReplay": true}} {"at": 1478, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "ff373ed9-cde2-4a49-b83f-09157a686ee8", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"} @@ -19,7 +19,7 @@ {"at": 10885, "kind": "frame", "frame": {"duration_api_ms": 11864, "stop_reason": "end_turn", "session_id": "00000000-0000-4000-8000-00000000c1a0", "total_cost_usd": 0.0398048, "usage": {"input_tokens": 2, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 23004, "output_tokens": 1497, "output_tokens_details": {"thinking_tokens": 0}, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "not_available", "iterations": [{"input_tokens": 2, "output_tokens": 1497, "cache_read_input_tokens": 23004, "cache_creation_input_tokens": 0, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, "type": "message"}], "speed": "standard"}, "modelUsage": {"claude-opus-5-5[1m]": {"inputTokens": 1211, "outputTokens": 1518, "cacheReadInputTokens": 23004, "cacheCreationInputTokens": 0, "webSearchRequests": 0, "costUSD": 0.0398048, "contextWindow": 1000000, "maxOutputTokens": 128000, "thinkingTokens": 0, "canonicalModel": "claude-opus-5-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "terminal_reason": "completed", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "is_error": false, "num_turns": 1, "subtype": "success", "api_error_status": null, "result": "one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\nthirteen\nfourteen\nfifteen\nsixteen\nseventeen\neighteen\nnineteen\ntwenty\ntwenty-one\ntwenty-two\ntwenty-three\ntwenty-four\ntwenty-five\ntwenty-six\ntwenty-seven\ntwenty-eight\ntwenty-nine\nthirty\nthirty-one\nthirty-two\nthirty-three\nthirty-four\nthirty-five\nthirty-six\nthirty-seven\nthirty-eight\nthirty-nine\nforty\nforty-one\nforty-two\nforty-three\nforty-four\nforty-five\nforty-six\nforty-seven\nforty-eight\nforty-nine\nfifty\nfifty-one\nfifty-two\nfifty-three\nfifty-four\nfifty-five\nfifty-six\nfifty-seven\nfifty-eight\nfifty-nine\nsixty\nsixty-one\nsixty-two\nsixty-three\nsixty-four\nsixty-five\nsixty-six\nsixty-seven\nsixty-eight\nsixty-nine\nseventy\nseventy-one\nseventy-two\nseventy-three\nseventy-four\nseventy-five\nseventy-six\nseventy-seven\nseventy-eight\nseventy-nine\neighty\neighty-one\neighty-two\neighty-three\neighty-four\neighty-five\neighty-six\neighty-seven\neighty-eight\neighty-nine\nninety\nninety-one\nninety-two\nninety-three\nninety-four\nninety-five\nninety-six\nninety-seven\nninety-eight\nninety-nine\none hundred\none hundred one\none hundred two\none hundred three\none hundred four\none hundred five\none hundred six\none hundred seven\none hundred eight\none hundred nine\none hundred ten\none hundred eleven\none hundred twelve\none hundred thirteen\none hundred fourteen\none hundred fifteen\none hundred sixteen\none hundred seventeen\none hundred eighteen\none hundred nineteen\none hundred twenty\none hundred twenty-one\none hundred twenty-two\none hundred twenty-three\none hundred twenty-four\none hundred twenty-five\none hundred twenty-six\none hundred twenty-seven\none hundred twenty-eight\none hundred twenty-nine\none hundred thirty\none hundred thirty-one\none hundred thirty-two\none hundred thirty-three\none hundred thirty-four\none hundred thirty-five\none hundred thirty-six\none hundred thirty-seven\none hundred thirty-eight\none hundred thirty-nine\none hundred forty\none hundred forty-one\none hundred forty-two\none hundred forty-three\none hundred forty-four\none hundred forty-five\none hundred forty-six\none hundred forty-seven\none hundred forty-eight\none hundred forty-nine\none hundred fifty\none hundred fifty-one\none hundred fifty-two\none hundred fifty-three\none hundred fifty-four\none hundred fifty-five\none hundred fifty-six\none hundred fifty-seven\none hundred fifty-eight\none hundred fifty-nine\none hundred sixty\none hundred sixty-one\none hundred sixty-two\none hundred sixty-three\none hundred sixty-four\none hundred sixty-five\none hundred sixty-six\none hundred sixty-seven\none hundred sixty-eight\none hundred sixty-nine\none hundred seventy\none hundred seventy-one\none hundred seventy-two\none hundred seventy-three\none hundred seventy-four\none hundred seventy-five\none hundred seventy-six\none hundred seventy-seven\none hundred seventy-eight\none hundred seventy-nine\none hundred eighty\none hundred eighty-one\none hundred eighty-two\none hundred eighty-three\none hundred eighty-four\none hundred eighty-five\none hundred eighty-six\none hundred eighty-seven\none hundred eighty-eight\none hundred eighty-nine\none hundred ninety\none hundred ninety-one\none hundred ninety-two\none hundred ninety-three\none hundred ninety-four\none hundred ninety-five\none hundred ninety-six\none hundred ninety-seven\none hundred ninety-eight\none hundred ninety-nine\ntwo hundred", "ttft_ms": 10419, "type": "result", "duration_ms": 10453, "uuid": "922da184-ba96-4a9e-a133-27dd7f45818f", "ttft_stream_ms": 1063, "time_to_request_ms": 71, "first_content_frame_ms": 1063, "user_message_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "user_message_uuids": ["0e1f8935-c61a-480b-8877-72c10dd1adda"], "request_sent_wall_ms": 1790678174393, "queued_turn_count": 0, "result_index": 0}} {"at": 10886, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "0e1f8935-c61a-480b-8877-72c10dd1adda", "state": "completed", "uuid": "3df6d400-c72e-4521-92f4-cc286ab007b7", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} {"at": 10886, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "state": "started", "uuid": "e7087639-8080-4803-b8e4-638cc31888e5", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} -{"at": 10904, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_scrubbed01_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "6fce32e9-e653-477f-b6b6-4d10267b2991", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 10904, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "6fce32e9-e653-477f-b6b6-4d10267b2991", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} {"at": 10904, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "e546544b-76be-4d78-9094-9a8329a6a756"}} {"at": 12309, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Reply with exactly: C-DONE"}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "timestamp": "2026-09-29T10:36:24.795Z", "isReplay": true}} {"at": 12310, "kind": "frame", "frame": {"type": "assistant", "message": {"model": "claude-opus-5-5", "id": "msg_scrubbed03", "type": "message", "role": "assistant", "content": [{"type": "text", "text": "C-DONE"}], "container": null, "stop_reason": null, "stop_sequence": null, "stop_details": null, "usage": {"input_tokens": 4, "cache_creation_input_tokens": 9027, "cache_read_input_tokens": 15502, "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 9027}, "output_tokens": 7, "service_tier": "standard", "inference_geo": "not_available"}, "diagnostics": null, "context_management": null}, "parent_tool_use_id": null, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "63316a0d-face-40ab-b7c5-a2f3c7aef73b", "timestamp": "2026-09-29T10:36:26.216Z", "request_id": "req_scrubbed02", "user_message_uuid": "19ec4a51-3c43-4197-9305-f7e6e5ae96c5", "user_message_uuids": ["19ec4a51-3c43-4197-9305-f7e6e5ae96c5"]}} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl index a133cf2523e9..69284b8119d5 100644 --- a/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-cancel-async.jsonl @@ -5,7 +5,7 @@ {"at": 261, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "3746879b-3bb8-47f8-a371-9544bc5812a5", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} {"at": 261, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "state": "queued", "uuid": "a75f748a-f917-4f8a-afea-28f022859753", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} {"at": 261, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "state": "started", "uuid": "e204ce9a-ee8a-4b64-ba12-eff1d33c52d1", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} -{"at": 301, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_scrubbed01_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "dad169fc-bced-4e8f-a40f-f3ea0537c763", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 301, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "dad169fc-bced-4e8f-a40f-f3ea0537c763", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} {"at": 302, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "f33c5acc-1608-453c-86a0-2568002a67d0"}} {"at": 1096, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "f3cd976f-dc3f-4eca-8622-ebf4e5b1d679", "timestamp": "2026-09-29T10:35:47.486Z", "isReplay": true}} {"at": 1097, "kind": "dispatch", "clientMessageId": "client-B", "sentUuid": "1ebf4091-6507-4b8b-838b-93c95875eded", "text": "Reply with text only; do not use any tools. Reply with exactly: B-DONE"} diff --git a/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl b/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl index 4242490be096..35230188f882 100644 --- a/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl +++ b/src/main/claude/__fixtures__/claude-lifecycle-capture-interrupt-lost.jsonl @@ -5,7 +5,7 @@ {"at": 421, "kind": "frame", "frame": {"type": "system", "subtype": "session_state_changed", "state": "running", "uuid": "ef60bf56-2954-4f1a-bfaf-502f4cf7e5f9", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} {"at": 421, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "queued", "uuid": "8f4de258-0da7-4ff7-a972-24c5d2b9e593", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} {"at": 422, "kind": "frame", "frame": {"type": "command_lifecycle", "command_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "state": "started", "uuid": "e70550c4-18ff-4795-9ddf-9dd267e82b9e", "session_id": "00000000-0000-4000-8000-00000000c1a0"}} -{"at": 488, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_scrubbed01_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "31b93145-d113-4141-adb1-2174b07845cd", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} +{"at": 488, "kind": "frame", "frame": {"type": "system", "subtype": "init", "cwd": "/Users/user/scrubbed", "session_id": "00000000-0000-4000-8000-00000000c1a0", "tools": [], "mcp_servers": [], "model": "claude-opus-5-5[1m]", "permissionMode": "default", "slash_commands": [], "terminal_slash_commands": [], "apiKeySource": "none", "claude_code_version": "2.1.280", "output_style": "default", "agents": [], "skills": [], "plugins": [], "capabilities": ["interrupt_receipt_v1", "interrupt_cancel_queued_v1", "msg_lifecycle_v1", "mcp_read_resource_v1", "mcp_tool_ui_meta_v1"], "analytics_disabled": false, "product_feedback_disabled": false, "uuid": "31b93145-d113-4141-adb1-2174b07845cd", "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required"}} {"at": 490, "kind": "frame", "frame": {"type": "system", "subtype": "status", "status": "requesting", "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "38344e0c-d00c-40dd-9da2-6cd73d7ad800"}} {"at": 2520, "kind": "frame", "frame": {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": "Reply with text only; do not use any tools. Write the numbers from one to two hundred in English words, one per line, lowercase, nothing else."}]}, "session_id": "00000000-0000-4000-8000-00000000c1a0", "parent_tool_use_id": null, "uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda", "timestamp": "2026-09-29T10:35:25.741Z", "isReplay": true}} {"at": 3136, "kind": "frame", "frame": {"type": "system", "subtype": "thinking_tokens", "estimated_tokens": 50, "estimated_tokens_delta": 50, "session_id": "00000000-0000-4000-8000-00000000c1a0", "uuid": "d15945e1-6168-458e-9b2d-49f427327af2", "user_message_uuid": "d0649b29-11d5-45e7-9034-6fb789e78eda"}} diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 1f3befff71fa..c5570e476fd1 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -94,6 +94,8 @@ async function replayCapture( withoutCapabilities?: string[] /** Leave out captured frames, to model a sequence the capture brackets. */ omitFrame?: (frame: Record) => boolean + /** Frames to deliver right after a captured one, in the capture's own uuids. */ + afterFrame?: (frame: Record) => Record[] /** Stop after the control request settles; its tail answers the CLI's own control path. */ stopAfterControl?: boolean } = {} @@ -166,6 +168,9 @@ async function replayCapture( ) } connection.handlers.onMessage?.(mapped) + for (const extra of options.afterFrame?.(frame) ?? []) { + connection.handlers.onMessage?.(mapUuids(extra)) + } } const liveUuid = (clientMessageId: string): string => { const dispatch = capture.find( @@ -377,6 +382,19 @@ describe('a send the CLI started, then cancelled', () => { expect(replay.settlementsFor('client-A')).toEqual([]) }) + + it('stays started when a redelivered command re-emits queued before its cancelled frame', async () => { + const replay = await replayCapture('auth-failed', { + omitFrame: (frame) => + frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result', + afterFrame: (frame) => + frame.type === 'command_lifecycle' && frame.state === 'started' + ? [{ ...frame, state: 'queued' }] + : [] + }) + + expect(replay.settlementsFor('client-A')).toEqual([]) + }) }) describe('the CLI reporting its session idle', () => { diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index b22ad2185f26..652a431d5383 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -24,7 +24,8 @@ export function observeClaudeCommandLifecycle( return } const state = message.state - if (state === 'queued' || state === 'started') { + if (state === 'started' || (state === 'queued' && waiter.commandLifecycle !== 'started')) { + // Forward only: a redelivered command re-emits `queued`, but it has still started. waiter.commandLifecycle = state } else if (state === 'cancelled' && waiter.commandLifecycle !== 'started') { settleCancelledClaudeDispatchWaiters(session, [waiter.sentUuid], onSettledLate) From 15078d126cd2dfcda552885c03c9727b05284dd7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:25:58 -0700 Subject: [PATCH 04/11] fix(claude): settle every terminal lifecycle state of a send the CLI never echoed A send the CLI started, then cancelled before any echo, stayed pending: it may already be in the conversation, so it is released as doubt (unknown, recovered), never withdrawn and never re-sent. A late echo still accepts it. The 2.1.280 schema has two more terminal states. `discarded` (the CLI ended its session with the send still queued) settles as not delivered; `refused` (declined before it queued) settles as not accepted by the provider. After `started`, either one is doubt, as `cancelled` is. The late-settlement path gains an `unknown` outcome, which the host records as released doubt. --- .../claude/claude-command-lifecycle.test.ts | 97 ++++++++++++++++--- src/main/claude/claude-command-lifecycle.ts | 45 +++++++-- src/main/claude/claude-structured-dispatch.ts | 47 +++++++-- .../claude/claude-structured-session-state.ts | 2 + .../journal-dispatch-doubt-reasons.ts | 3 + ...structured-agent-session-host-mutations.ts | 26 +++-- ...e-structured-idle-releases-unknown.test.ts | 58 ++++++++++- 7 files changed, 242 insertions(+), 36 deletions(-) diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index c5570e476fd1..7d276478a026 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -10,7 +10,10 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' +import { DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { agentSessionFailureFact } from '../../shared/agent-session-failure' import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' +import { claudeDispatchRejection } from './claude-structured-dispatch-content' import { ClaudeStructuredSessionAdapter } from './claude-structured-session-adapter' import type { ClaudeLateDispatchOutcome } from './claude-structured-session-state' import { @@ -96,6 +99,8 @@ async function replayCapture( omitFrame?: (frame: Record) => boolean /** Frames to deliver right after a captured one, in the capture's own uuids. */ afterFrame?: (frame: Record) => Record[] + /** Deliver this in place of a captured frame, as a CLI reporting another state would. */ + rewriteFrame?: (frame: Record) => Record /** Stop after the control request settles; its tail answers the CLI's own control path. */ stopAfterControl?: boolean } = {} @@ -161,7 +166,7 @@ async function replayCapture( if (options.omitFrame?.(frame)) { return } - const mapped = mapUuids(frame) + const mapped = mapUuids(options.rewriteFrame?.(frame) ?? frame) if (Array.isArray(mapped.capabilities) && options.withoutCapabilities) { mapped.capabilities = mapped.capabilities.filter( (capability) => !options.withoutCapabilities!.includes(String(capability)) @@ -366,6 +371,25 @@ describe('a queued send the CLI withdraws without Orca hearing why', () => { }) }) +/** The auth-failed capture's turn output: without it, its send started and got no echo. */ +function isTurnOutput(frame: Record): boolean { + return frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result' +} + +function isLifecycleFrame(frame: Record, state?: string): boolean { + return frame.type === 'command_lifecycle' && (state === undefined || frame.state === state) +} + +/** The uuid a capture recorded for one of its sends, as its own frames name it. */ +function capturedUuid(name: string, clientMessageId: string): string { + const dispatch = loadCapture(name).find( + (event) => event.kind === 'dispatch' && event.clientMessageId === clientMessageId + ) + return dispatch?.kind === 'dispatch' ? dispatch.sentUuid : '' +} + +const ENDED_IN_DOUBT = { state: 'unknown', reason: DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } + describe('a send the CLI started, then cancelled', () => { it('stays accepted when its turn failed after the echo', async () => { const replay = await replayCapture('auth-failed') @@ -373,27 +397,72 @@ describe('a send the CLI started, then cancelled', () => { expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) }) - it('is not read as withdrawn when the cancelled frame came before any echo', async () => { - // The auth-failed capture with the turn's output removed: started, then cancelled. - const replay = await replayCapture('auth-failed', { - omitFrame: (frame) => - frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result' - }) + it('is released as doubt, never withdrawn, when the cancelled frame came before any echo', async () => { + const replay = await replayCapture('auth-failed', { omitFrame: isTurnOutput }) - expect(replay.settlementsFor('client-A')).toEqual([]) + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) }) it('stays started when a redelivered command re-emits queued before its cancelled frame', async () => { const replay = await replayCapture('auth-failed', { - omitFrame: (frame) => - frame.type === 'user' || frame.type === 'assistant' || frame.type === 'result', + omitFrame: isTurnOutput, afterFrame: (frame) => - frame.type === 'command_lifecycle' && frame.state === 'started' - ? [{ ...frame, state: 'queued' }] - : [] + isLifecycleFrame(frame, 'started') ? [{ ...frame, state: 'queued' }] : [] + }) + + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) + }) + + it('is still accepted when its echo lands after the doubt', async () => { + const replay = await replayCapture('auth-failed', { omitFrame: isTurnOutput }) + const uuid = replay.liveUuid('client-A') + + replay.connection.handlers.onMessage?.({ + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: 'late echo' }] }, + parent_tool_use_id: null, + session_id: PROVIDER_SESSION_ID, + uuid + }) + + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT, acceptedAs(uuid)]) + }) + + it.each(['discarded', 'refused'])( + 'is released as doubt when its turn ends %s after it started', + async (state) => { + const replay = await replayCapture('auth-failed', { + omitFrame: isTurnOutput, + rewriteFrame: (frame) => + isLifecycleFrame(frame, 'cancelled') ? { ...frame, state } : frame + }) + + expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) + } + ) +}) + +describe('a send the CLI ended before starting it', () => { + it.each([ + // The CLI ended its session with the send still queued. + ['discarded', 'notDelivered', false], + // Declined before it queued, so no `queued` frame precedes it. + ['refused', 'providerRejected', true] + ] as const)('settles a %s send as not sent, saying why', async (state, kind, neverQueued) => { + const followUp = capturedUuid('cancel-async', 'client-B') + const replay = await replayCapture('cancel-async', { + omitFrame: (frame) => + neverQueued && isLifecycleFrame(frame, 'queued') && frame.command_uuid === followUp, + rewriteFrame: (frame) => + isLifecycleFrame(frame, 'cancelled') && frame.command_uuid === followUp + ? { ...frame, state } + : frame }) - expect(replay.settlementsFor('client-A')).toEqual([]) + expect(replay.settlementsFor('client-B')).toEqual([ + { state: 'rejected', ...claudeDispatchRejection(agentSessionFailureFact(kind)) } + ]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) }) }) diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index 652a431d5383..365a8b583315 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -1,15 +1,34 @@ // What Claude's per-command `command_lifecycle` frames (msg_lifecycle_v1) settle. // -// `cancelled` is not by itself a withdrawal: a command the CLI already started also ends -// `cancelled` when its turn is interrupted or fails (measured on 2.1.280). Only a command -// cancelled before it started was withdrawn. That frame lands ahead of the control answer, so -// it settles the send even when the interrupt or cancel_async_message answer is lost. +// A terminal state for a command that never started is a verdict: `cancelled` was withdrawn, +// `discarded` was dropped when the CLI ended its session, `refused` was declined before it +// queued. None ran. Once a command has started, the same states only mean its turn ended — an +// interrupt or a hard failure (measured on 2.1.280) — so it may be in the conversation: doubt. +// These frames land ahead of the control answer, so they settle a send even when that is lost. -import { settleCancelledClaudeDispatchWaiters } from './claude-structured-dispatch' +import { + agentSessionFailureFact, + type SubmissionRejectionKind +} from '../../shared/agent-session-failure' +import { DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { + rejectClaudeDispatchWaiters, + releaseClaudeDispatchWaitersInDoubt +} from './claude-structured-dispatch' import { readClaudeFrameString } from './claude-structured-init-proof' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import type { ClaudeSession } from './claude-structured-session-state' +const UNSTARTED_VERDICT = { + cancelled: 'cancelled', + discarded: 'notDelivered', + refused: 'providerRejected' +} satisfies Record + +function isTerminalState(state: unknown): state is keyof typeof UNSTARTED_VERDICT { + return state === 'cancelled' || state === 'discarded' || state === 'refused' +} + export function observeClaudeCommandLifecycle( session: ClaudeSession, message: Record, @@ -27,7 +46,19 @@ export function observeClaudeCommandLifecycle( if (state === 'started' || (state === 'queued' && waiter.commandLifecycle !== 'started')) { // Forward only: a redelivered command re-emits `queued`, but it has still started. waiter.commandLifecycle = state - } else if (state === 'cancelled' && waiter.commandLifecycle !== 'started') { - settleCancelledClaudeDispatchWaiters(session, [waiter.sentUuid], onSettledLate) + } else if (isTerminalState(state) && waiter.commandLifecycle === 'started') { + releaseClaudeDispatchWaitersInDoubt( + session, + [waiter], + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + onSettledLate + ) + } else if (isTerminalState(state)) { + rejectClaudeDispatchWaiters( + session, + [waiter.sentUuid], + agentSessionFailureFact(UNSTARTED_VERDICT[state]), + onSettledLate + ) } } diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 612f6391ced5..223ede5550cc 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -7,7 +7,7 @@ import { } from './claude-structured-dispatch-waiters' import type { AgentJournalMessageItem } from '../../shared/agent-session-journal-types' import type { AgentSessionDispatchOutcome } from '../native-chat/agent-session-wire/structured-agent-session-adapter' -import type { ClaudeSession } from './claude-structured-session-state' +import type { ClaudeDispatchWaiter, ClaudeSession } from './claude-structured-session-state' import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resolution' import { claudeDispatchContentKey, @@ -18,7 +18,10 @@ import { } from './claude-structured-dispatch-content' import { dispatchWriteOutcomeUnknownReason } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { DISPATCH_REJECTED_QUEUE_FULL } from '../../shared/structured-agent-session-dispatch-rejection' -import { agentSessionFailureFact } from '../../shared/agent-session-failure' +import { + agentSessionFailureFact, + type SubmissionRejectionFact +} from '../../shared/agent-session-failure' import type { AgentJournalDispatchRejection } from '../../shared/agent-session-failure-words' import { claudeUnwrittenUserMessageError, @@ -37,10 +40,25 @@ export function settleCancelledClaudeDispatchWaiters( cancelledUuids: readonly string[], onSettledLate?: ClaudeLateDispatchSettlement ): void { - const cancelled = new Set(cancelledUuids) - const activeWaiters = session.dispatchWaiters.filter((waiter) => cancelled.has(waiter.sentUuid)) + rejectClaudeDispatchWaiters( + session, + cancelledUuids, + agentSessionFailureFact('cancelled'), + onSettledLate + ) +} + +/** Settles sends the CLI proved it will never run, each with the fact that says why. */ +export function rejectClaudeDispatchWaiters( + session: ClaudeSession, + uuids: readonly string[], + fact: SubmissionRejectionFact, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const named = new Set(uuids) + const activeWaiters = session.dispatchWaiters.filter((waiter) => named.has(waiter.sentUuid)) const retiredWaiters = session.retiredDispatchWaiters.filter((waiter) => - cancelled.has(waiter.sentUuid) + named.has(waiter.sentUuid) ) for (const waiter of activeWaiters) { forgetWaiter(session, waiter) @@ -54,12 +72,29 @@ export function settleCancelledClaudeDispatchWaiters( onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'rejected', - ...claudeDispatchRejection(agentSessionFailureFact('cancelled')) + ...claudeDispatchRejection(fact) }) } } } +/** Releases sends the CLI took and let go without an echo or a verdict: each may have run, so each + * settles as doubt, never re-sent. Retired, not dropped, so a late replay still accepts it. */ +export function releaseClaudeDispatchWaitersInDoubt( + session: ClaudeSession, + waiters: readonly ClaudeDispatchWaiter[], + reason: string, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + for (const waiter of waiters) { + retireWaiter(session, waiter) + waiter.resolve(null) + if (waiter.clientMessageId) { + onSettledLate?.({ clientMessageId: waiter.clientMessageId, state: 'unknown', reason }) + } + } +} + /** Nothing expires a waiter, so the child's death is what ends every live one. * Retired rather than dropped: their identities stay joinable, bounded by * `MAX_RETIRED_DISPATCH_WAITERS`. */ diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index b2e1ea5bf1b1..dcfa9b95f334 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -87,6 +87,8 @@ export type ClaudeLateDispatchOutcome = providerIdentity: AgentJournalItemIdentity } | ({ clientMessageId: string; state: 'rejected' } & AgentJournalDispatchRejection) + /** The CLI took the send and let it go unanswered: it may have run, so it is never re-sent. */ + | { clientMessageId: string; state: 'unknown'; reason: string } export type ClaudeStructuredSessionAdapterDeps = { resolveLaunch: (input: { diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts index 6c01e6224dc0..1aaa0b76df95 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-doubt-reasons.ts @@ -27,6 +27,9 @@ export const DISPATCH_DOUBT_SUBMISSION_MISSING = 'durable_send_submission_missin * left that could still acknowledge the message. */ export const DISPATCH_DOUBT_PROVIDER_IDLE = 'provider_idle_before_acknowledgement' +/** The provider started the message, then ended its turn before acknowledging it. */ +export const DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED = 'provider_ended_before_acknowledgement' + /** The SDK took the frame, but its input pump did not prove whether the write completed. */ export const DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN = 'provider_write_outcome_unknown' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 1586bd4a1c16..74418e98f31a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -264,7 +264,8 @@ export function changeStructuredAgentSessionThreadGoal( ) } -/** Settle provider-proven delivery independently of an in-flight client mutation. */ +/** Settle a provider-proven outcome independently of an in-flight client mutation. `unknown` is + * released doubt: the provider let the send go unanswered, so nothing re-sends it. */ export async function settleStructuredAgentSessionLateDispatch( context: StructuredAgentSessionMutationContext, input: { @@ -273,6 +274,7 @@ export async function settleStructuredAgentSessionLateDispatch( } & ( | { providerIdentity: AgentJournalItemIdentity } | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string } ) ): Promise { const session = context.sessions.get(input.sessionId) @@ -289,13 +291,21 @@ export async function settleStructuredAgentSessionLateDispatch( providerIdentity: input.providerIdentity, fence } - : { - clientMessageId: input.clientMessageId, - state: 'rejected', - reason: input.reason, - rejection: input.rejection, - fence - } + : input.state === 'unknown' + ? { + clientMessageId: input.clientMessageId, + state: 'unknown', + reason: input.reason, + fence, + recovered: true + } + : { + clientMessageId: input.clientMessageId, + state: 'rejected', + reason: input.reason, + rejection: input.rejection, + fence + } ) } diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index becdc9f2b389..1ab534f82610 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -6,7 +6,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' -import { DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN +} from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { hasUnansweredStructuredAgentSessionDispatch } from '../../shared/structured-agent-session-unanswered-dispatch' import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' @@ -106,3 +110,55 @@ describe('a live Claude chat whose CLI reports idle', () => { expect((await submission(host, pending))?.recovered).toBeUndefined() }) }) + +function lifecycle(commandUuid: string, state: string): Record { + return { + type: 'command_lifecycle', + command_uuid: commandUuid, + state, + uuid: `lifecycle-${state}`, + session_id: PROVIDER_SESSION + } +} + +describe('a live Claude chat whose CLI took a send and let it go without an echo', () => { + it.each([['its turn ended cancelled', 'cancelled', DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED]])( + 'records released doubt when %s', + async (_label, ending, reason) => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const child = claude.child(SESSION) + let sentUuid = '' + child.connection.send = async (message, beforeDispatch) => { + await beforeDispatch?.() + sentUuid = String(message.uuid) + } + const taken = await send(host, 'retrying') + // The pending row lands before the adapter's write; the write is what names the uuid. + await vi.waitFor(() => expect(sentUuid).not.toBe('')) + expect(await submission(host, taken)).toMatchObject({ dispatchState: 'pending' }) + child.handlers.onMessage?.(lifecycle(sentUuid, 'queued')) + child.handlers.onMessage?.(lifecycle(sentUuid, 'started')) + + child.handlers.onMessage?.( + ending === 'idle' ? sessionState('idle') : lifecycle(sentUuid, ending) + ) + + await vi.waitFor(async () => + expect(await submission(host, taken)).toMatchObject({ + dispatchState: 'unknown', + recovered: true, + reason + }) + ) + // Released doubt: the chat stops reading working, and nothing re-sends it. + expect( + hasUnansweredStructuredAgentSessionDispatch( + (await host.journalSnapshot(SESSION)).submissions + ) + ).toBe(false) + } + ) +}) From 0a36dab302ee38096bdd433063813e36a11b56d1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:26:36 -0700 Subject: [PATCH 05/11] fix(claude): release a send the CLI took but left unanswered when it goes idle `session_state_changed idle` comes only once the CLI's queue has drained, so a send it took that is still unanswered there got no echo and never will: a turn that throws can leave `started` with no terminal state. Idle releases it as doubt. What proves the CLI took a send is its lifecycle frame. On a CLI that reports no lifecycle, it is the send's place on stdin: one whose write finished before an interrupt went out was read before the interrupt was, so the first idle after that interrupt releases it too. A send armed ahead of the interrupt but written after it is left alone, since the CLI may still run it. --- .../claude/claude-command-lifecycle.test.ts | 98 ++++++++++++++++++- src/main/claude/claude-command-lifecycle.ts | 21 +++- .../claude/claude-replay-turn-resolution.ts | 14 ++- .../claude-structured-control-actions.ts | 9 ++ src/main/claude/claude-structured-dispatch.ts | 3 + .../claude/claude-structured-session-state.ts | 4 + ...e-structured-idle-releases-unknown.test.ts | 75 +++++++------- 7 files changed, 181 insertions(+), 43 deletions(-) diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 7d276478a026..03928e834530 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -10,7 +10,10 @@ import { describe, expect, it } from 'vitest' import type { AgentJournalItemBody } from '../../shared/agent-session-journal-types' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { DISPATCH_REJECTED_CANCELLED } from '../../shared/structured-agent-session-dispatch-rejection' -import { DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + DISPATCH_DOUBT_PROVIDER_IDLE +} from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { agentSessionFailureFact } from '../../shared/agent-session-failure' import { ClaudeControlRequestError } from './claude-agent-sdk-control-requests' import { claudeDispatchRejection } from './claude-structured-dispatch-content' @@ -380,6 +383,10 @@ function isLifecycleFrame(frame: Record, state?: string): boole return frame.type === 'command_lifecycle' && (state === undefined || frame.state === state) } +function isIdleFrame(frame: Record): boolean { + return frame.subtype === 'session_state_changed' && frame.state === 'idle' +} + /** The uuid a capture recorded for one of its sends, as its own frames name it. */ function capturedUuid(name: string, clientMessageId: string): string { const dispatch = loadCapture(name).find( @@ -389,6 +396,7 @@ function capturedUuid(name: string, clientMessageId: string): string { } const ENDED_IN_DOUBT = { state: 'unknown', reason: DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } +const IDLE_IN_DOUBT = { state: 'unknown', reason: DISPATCH_DOUBT_PROVIDER_IDLE } describe('a send the CLI started, then cancelled', () => { it('stays accepted when its turn failed after the echo', async () => { @@ -466,6 +474,94 @@ describe('a send the CLI ended before starting it', () => { }) }) +describe('a send the CLI took when it goes idle', () => { + it('is released as doubt when its started turn threw with no terminal state', async () => { + // Started, then its turn threw: no echo and no terminal state, as the schema allows. + const replay = await replayCapture('auth-failed', { + omitFrame: (frame) => + isTurnOutput(frame) || isLifecycleFrame(frame, 'cancelled') || isIdleFrame(frame) + }) + expect(replay.settlementsFor('client-A')).toEqual([]) + + replay.connection.handlers.onMessage?.({ + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + uuid: 'idle-after-throw', + session_id: PROVIDER_SESSION_ID + }) + + expect(replay.settlementsFor('client-A')).toEqual([IDLE_IN_DOUBT]) + }) +}) + +describe('a Stop on a CLI that reports no lifecycle', () => { + const OLDER_CLI = ['msg_lifecycle_v1', 'interrupt_receipt_v1', 'interrupt_cancel_queued_v1'] + const withoutLifecycle = (frame: Record) => isLifecycleFrame(frame) + + it('releases the send the interrupt caught at the next idle, and never one written after it', async () => { + const replay = await replayCapture('interrupt-lost', { + withoutCapabilities: OLDER_CLI, + omitFrame: withoutLifecycle, + atControl: async (point) => { + // Armed ahead of the interrupt, but its write finishes after it. + const send = point.connection.send + const gated = Promise.withResolvers() + const reachedGate = Promise.withResolvers() + point.connection.send = async (message, beforeDispatch) => { + point.connection.send = send + await beforeDispatch?.() + reachedGate.resolve() + await gated.promise + point.connection.sent.push(message) + } + const unwritten = point.adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-D', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'D' }] }, + fence: 7 + }) + await reachedGate.promise + // An older CLI's interrupt answers with no receipt. + point.routes.interrupt = () => { + point.deliverInFlight() + return {} + } + await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + gated.resolve() + await expect(unwritten).resolves.toEqual({ state: 'admitted' }) + await expect( + point.adapter.dispatch({ + sessionId: 'session-1', + clientMessageId: 'client-C', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'C' }] }, + fence: 7 + }) + ).resolves.toEqual({ state: 'admitted' }) + } + }) + + expect(replay.connection.calls.filter((call) => call.subtype === 'interrupt')).toEqual([ + { subtype: 'interrupt', params: {} } + ]) + expect(replay.settlementsFor('client-B')).toEqual([IDLE_IN_DOUBT]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) + // Read after the interrupt, so the CLI may still run them. + expect(replay.settlementsFor('client-C')).toEqual([]) + expect(replay.settlementsFor('client-D')).toEqual([]) + }) + + it('leaves a written send pending at idle when no interrupt came after it', async () => { + const replay = await replayCapture('interrupt-lost', { + withoutCapabilities: OLDER_CLI, + omitFrame: withoutLifecycle + }) + + expect(replay.idles).toEqual(['session-1']) + expect(replay.settlementsFor('client-B')).toEqual([]) + }) +}) + describe('the CLI reporting its session idle', () => { it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( 'is reported once per idle frame (%s), never for running', diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index 365a8b583315..0140956b5ac3 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -10,7 +10,10 @@ import { agentSessionFailureFact, type SubmissionRejectionKind } from '../../shared/agent-session-failure' -import { DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' +import { + DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + DISPATCH_DOUBT_PROVIDER_IDLE +} from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { rejectClaudeDispatchWaiters, releaseClaudeDispatchWaitersInDoubt @@ -62,3 +65,19 @@ export function observeClaudeCommandLifecycle( ) } } + +/** + * `session_state_changed idle` comes only once the CLI's queue has drained, so a send it took that + * is still unanswered here left without an echo — a turn that threw can leave `started` with no + * terminal state. What proves it took one is its lifecycle frame or, on a CLI with none, its + * position on stdin: written ahead of an interrupt, it was read before the interrupt was. + */ +export function releaseClaudeDispatchesUnansweredAtIdle( + session: ClaudeSession, + onSettledLate?: ClaudeLateDispatchSettlement +): void { + const taken = session.dispatchWaiters.filter( + (waiter) => waiter.commandLifecycle !== undefined || waiter.writtenBeforeInterrupt + ) + releaseClaudeDispatchWaitersInDoubt(session, taken, DISPATCH_DOUBT_PROVIDER_IDLE, onSettledLate) +} diff --git a/src/main/claude/claude-replay-turn-resolution.ts b/src/main/claude/claude-replay-turn-resolution.ts index 9db40feac49e..fe2bb1523f95 100644 --- a/src/main/claude/claude-replay-turn-resolution.ts +++ b/src/main/claude/claude-replay-turn-resolution.ts @@ -3,10 +3,14 @@ // A replay or result is joined to its waiter by the client uuid Claude echoes. // A send Claude FOLDS into the running request cycle is replayed mid-cycle with // the client uuid adopted: once that cycle has done work, that replay is a -// delivery receipt and opens no boundary. A `command_lifecycle` frame opens no turn; its -// `cancelled` can settle a send the CLI withdrew (`claude-command-lifecycle.ts`). +// delivery receipt and opens no boundary. A `command_lifecycle` frame opens no turn; it and the +// CLI going idle settle sends the CLI took but never echoed (`claude-command-lifecycle.ts`). -import { observeClaudeCommandLifecycle } from './claude-command-lifecycle' +import { + observeClaudeCommandLifecycle, + releaseClaudeDispatchesUnansweredAtIdle +} from './claude-command-lifecycle' +import { claudeSessionStateEndsTurn } from './claude-session-state-turn-over' import { forgetRetiredWaiter } from './claude-structured-dispatch-waiters' import { claudeHasReplayContent, @@ -34,6 +38,10 @@ export function resolveClaudeReplayTurn( observeClaudeCommandLifecycle(session, message, onSettledLate) return null } + if (claudeSessionStateEndsTurn(message)) { + releaseClaudeDispatchesUnansweredAtIdle(session, onSettledLate) + return null + } const envelope = readClaudeMessageEnvelope(message) const isUserReplay = envelope?.role === 'user' && diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 8ec1aa2e9c41..3ffdcb7eb737 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -6,6 +6,7 @@ import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resoluti import type { ClaudeSession } from './claude-structured-session-state' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' +const COMMAND_LIFECYCLE_CAPABILITY = 'msg_lifecycle_v1' export function supportsClaudeQueuedInterruptCancellation(session: ClaudeSession): boolean { return session.capabilities.includes(INTERRUPT_CANCEL_QUEUED_CAPABILITY) @@ -33,6 +34,14 @@ export async function cancelClaudeTurn( return { cancelled: false } } const cancelQueued = supportsClaudeQueuedInterruptCancellation(session) + // With no lifecycle frames, stdin order is the proof: the CLI reads these before the interrupt. + if (!session.capabilities.includes(COMMAND_LIFECYCLE_CAPABILITY)) { + for (const waiter of session.dispatchWaiters) { + if (waiter.written) { + waiter.writtenBeforeInterrupt = true + } + } + } try { const receipt = await session.connection.interrupt({ ...(cancelQueued ? { cancelQueued: true } : {}), diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 223ede5550cc..18cb735e5837 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -200,6 +200,9 @@ export async function dispatchClaudeTurn( } return { state: 'unknown', reason: dispatchWriteOutcomeUnknownReason(error) } } + if (pending.replay) { + pending.replay.waiter.written = true + } // The write is the admission signal. Awaiting the echo here would block on the // turn already running, which is why the deadline this replaces kept declaring // doubt about messages that were delivered. The replay resolution diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index dcfa9b95f334..fd3b0ef351a6 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -145,6 +145,10 @@ export type ClaudeDispatchWaiter = { retired?: boolean /** The CLI's last non-terminal `command_lifecycle` state for this send; in memory only. */ commandLifecycle?: 'queued' | 'started' + /** The SDK finished writing this send to the CLI's stdin. */ + written?: true + /** Written ahead of an interrupt, so a CLI that reports no lifecycle has read it by its idle. */ + writtenBeforeInterrupt?: true /** Bounded digest/summary for compatibility CLIs that mint UUIDs. */ replayContentKey: string } diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index 1ab534f82610..8e6107dce97e 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -8,6 +8,7 @@ import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-sessio import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' import { DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED, + DISPATCH_DOUBT_PROVIDER_IDLE, DISPATCH_DOUBT_WRITE_OUTCOME_UNKNOWN } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import { hasUnansweredStructuredAgentSessionDispatch } from '../../shared/structured-agent-session-unanswered-dispatch' @@ -122,43 +123,41 @@ function lifecycle(commandUuid: string, state: string): Record } describe('a live Claude chat whose CLI took a send and let it go without an echo', () => { - it.each([['its turn ended cancelled', 'cancelled', DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED]])( - 'records released doubt when %s', - async (_label, ending, reason) => { - const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ - ok: true - }) - const child = claude.child(SESSION) - let sentUuid = '' - child.connection.send = async (message, beforeDispatch) => { - await beforeDispatch?.() - sentUuid = String(message.uuid) - } - const taken = await send(host, 'retrying') - // The pending row lands before the adapter's write; the write is what names the uuid. - await vi.waitFor(() => expect(sentUuid).not.toBe('')) - expect(await submission(host, taken)).toMatchObject({ dispatchState: 'pending' }) - child.handlers.onMessage?.(lifecycle(sentUuid, 'queued')) - child.handlers.onMessage?.(lifecycle(sentUuid, 'started')) - - child.handlers.onMessage?.( - ending === 'idle' ? sessionState('idle') : lifecycle(sentUuid, ending) - ) - - await vi.waitFor(async () => - expect(await submission(host, taken)).toMatchObject({ - dispatchState: 'unknown', - recovered: true, - reason - }) - ) - // Released doubt: the chat stops reading working, and nothing re-sends it. - expect( - hasUnansweredStructuredAgentSessionDispatch( - (await host.journalSnapshot(SESSION)).submissions - ) - ).toBe(false) + it.each([ + ['its turn ended cancelled', 'cancelled', DISPATCH_DOUBT_PROVIDER_ENDED_UNANSWERED], + ['it went idle with no terminal state', 'idle', DISPATCH_DOUBT_PROVIDER_IDLE] + ])('records released doubt when %s', async (_label, ending, reason) => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const child = claude.child(SESSION) + let sentUuid = '' + child.connection.send = async (message, beforeDispatch) => { + await beforeDispatch?.() + sentUuid = String(message.uuid) } - ) + const taken = await send(host, 'retrying') + // The pending row lands before the adapter's write; the write is what names the uuid. + await vi.waitFor(() => expect(sentUuid).not.toBe('')) + expect(await submission(host, taken)).toMatchObject({ dispatchState: 'pending' }) + child.handlers.onMessage?.(lifecycle(sentUuid, 'queued')) + child.handlers.onMessage?.(lifecycle(sentUuid, 'started')) + + child.handlers.onMessage?.( + ending === 'idle' ? sessionState('idle') : lifecycle(sentUuid, ending) + ) + + await vi.waitFor(async () => + expect(await submission(host, taken)).toMatchObject({ + dispatchState: 'unknown', + recovered: true, + reason + }) + ) + // Released doubt: the chat stops reading working, and nothing re-sends it. + expect( + hasUnansweredStructuredAgentSessionDispatch((await host.journalSnapshot(SESSION)).submissions) + ).toBe(false) + }) }) From 2e13528c189ea232abfa5b45fb2f6ca574127327 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:27:23 -0700 Subject: [PATCH 06/11] fix(native-chat): keep the idle sweep off a Claude child that holds a send A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row exists yet and the sweep rested the child after the idle window, turning the send into doubt. The adapter now reports whether the CLI holds a send (lifecycle `queued` or `started`, not yet echoed or ended), derived from the live waiters, and owed work counts it. Nothing is stored: every held send leaves the live set on its echo, its terminal lifecycle state, the CLI's idle, or the child's exit, so the hold ends with the send. --- .../claude/claude-command-lifecycle.test.ts | 16 ++++++++++++++-- src/main/claude/claude-command-lifecycle.ts | 5 +++++ .../claude-structured-session-adapter.ts | 5 +++++ .../structured-agent-session-adapter-router.ts | 3 +++ .../structured-agent-session-adapter.ts | 3 +++ ...ed-agent-session-conversation-close.test.ts | 1 + ...ured-agent-session-conversation-lifetime.ts | 1 + ...structured-agent-session-idle-sweep.test.ts | 18 ++++++++++++++++++ .../structured-agent-session-idle-sweep.ts | 3 +++ .../structured-agent-session-rest-test-rig.ts | 2 ++ ...de-structured-idle-releases-unknown.test.ts | 2 ++ 11 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 03928e834530..9d6f58c39bfd 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -409,6 +409,7 @@ describe('a send the CLI started, then cancelled', () => { const replay = await replayCapture('auth-failed', { omitFrame: isTurnOutput }) expect(replay.settlementsFor('client-A')).toEqual([ENDED_IN_DOUBT]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) }) it('stays started when a redelivered command re-emits queued before its cancelled frame', async () => { @@ -474,13 +475,14 @@ describe('a send the CLI ended before starting it', () => { }) }) -describe('a send the CLI took when it goes idle', () => { - it('is released as doubt when its started turn threw with no terminal state', async () => { +describe('a send the CLI holds', () => { + it('holds its child from the idle sweep until the CLI idles, then is released as doubt', async () => { // Started, then its turn threw: no echo and no terminal state, as the schema allows. const replay = await replayCapture('auth-failed', { omitFrame: (frame) => isTurnOutput(frame) || isLifecycleFrame(frame, 'cancelled') || isIdleFrame(frame) }) + expect(replay.adapter.holdsDispatch('session-1')).toBe(true) expect(replay.settlementsFor('client-A')).toEqual([]) replay.connection.handlers.onMessage?.({ @@ -492,7 +494,17 @@ describe('a send the CLI took when it goes idle', () => { }) expect(replay.settlementsFor('client-A')).toEqual([IDLE_IN_DOUBT]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) }) + + it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( + 'holds nothing once the %s capture has run to its idle', + async (name) => { + const replay = await replayCapture(name) + + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + } + ) }) describe('a Stop on a CLI that reports no lifecycle', () => { diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index 0140956b5ac3..7f636724db6e 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -66,6 +66,11 @@ export function observeClaudeCommandLifecycle( } } +/** A send the CLI has taken and neither echoed nor ended: the idle sweep must not rest its child. */ +export function claudeHoldsDispatch(session: ClaudeSession): boolean { + return session.dispatchWaiters.some((waiter) => waiter.commandLifecycle !== undefined) +} + /** * `session_state_changed idle` comes only once the CLI's queue has drained, so a send it took that * is still unanswered here left without an echo — a turn that threw can leave `started` with no diff --git a/src/main/claude/claude-structured-session-adapter.ts b/src/main/claude/claude-structured-session-adapter.ts index 29b51e730ae9..bd01b4e49d39 100644 --- a/src/main/claude/claude-structured-session-adapter.ts +++ b/src/main/claude/claude-structured-session-adapter.ts @@ -7,6 +7,7 @@ import type { } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { stopClaudeBackgroundTasks } from './claude-structured-control-actions' import { dispatchClaudeTurn } from './claude-structured-dispatch' +import { claudeHoldsDispatch } from './claude-command-lifecycle' import { StructuredSessionCompaction } from '../native-chat/agent-session-wire/structured-session-compaction' import { releaseClaudeAcquisition } from './claude-structured-acquisition-release' import { acquireClaudeSession } from './claude-structured-session-acquisition' @@ -230,6 +231,10 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda } readCommands: NonNullable = (sessionId) => this.sessions.get(sessionId)?.commands.commands + holdsDispatch = (sessionId: string): boolean => { + const session = this.sessions.get(sessionId) + return session ? claudeHoldsDispatch(session) : false + } answerPrompt: StructuredAgentSessionAdapter['answerPrompt'] = (request) => answerClaudeStructuredPrompt({ request, sessions: this.sessions }) setOption: StructuredAgentSessionAdapter['setOption'] = (input) => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts index bb003f93427d..9a759ba0411b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter-router.ts @@ -112,6 +112,9 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi sessionId ) => this.liveOwnerOrNull(sessionId)?.backgroundTaskState?.(sessionId) + holdsDispatch = (sessionId: string): boolean => + this.liveOwnerOrNull(sessionId)?.holdsDispatch?.(sessionId) ?? false + readCommands: NonNullable = (sessionId) => this.liveOwnerOrNull(sessionId)?.readCommands?.(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index e4ef8eb85f88..df480721202f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -315,6 +315,9 @@ export type StructuredAgentSessionAdapter = { taskId?: string }): Promise<{ cancelled: boolean }> backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined + /** The provider reported taking a send it has neither answered nor ended, as a silent retry + * does. Derived from the live child; false with none. */ + holdsDispatch?(sessionId: string): boolean /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ readCommands?(sessionId: string): AgentSessionSlashCommand[] | undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index bf3cd1f96a60..977b0ff55cfd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -313,6 +313,7 @@ describe('the wind-down retry with a message queued (P2-31)', () => { deliveryActive: () => true, backgroundTaskState: () => undefined, hasOpenDispatch: () => false, + providerHoldsDispatch: () => false, stopAgent, stopStartingAgent: stopAgent, closeConversation: vi.fn(async () => false), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 0fbd1a0a233c..945ddd174558 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -69,6 +69,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { const record = deps().store.getRecord(sessionId) return record !== null && deps().hasOpenDispatch?.(record) === true }, + providerHoldsDispatch: (sessionId) => deps().adapter.holdsDispatch?.(sessionId) === true, stopAgent, // A host stop: the delivery loop waiting on this child writes the one error row and rejects // what is queued with it, both worded from the hostStopped fact. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts index ebc2009fa7db..90f882fc2f95 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.test.ts @@ -237,6 +237,23 @@ describe('the idle sweep', () => { await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) }) + // A Claude retrying a rate-limited request has taken the send but echoes nothing, so no turn row + // exists yet; only the provider can say it still holds the send. + it('never stops an agent while its provider holds a send, and gives a full window once it lets go', async () => { + await foundRestTestChat(rig) + rig.adapter.holdsDispatch.mockReturnValue(true) + rig.clock.now += 2 * IDLE_MS + + await sweepTicks(12) + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + expect(rig.adapter.holdsDispatch).toHaveBeenCalledWith(SESSION) + rig.adapter.holdsDispatch.mockReturnValue(false) + await sweepTicks() + expect(rig.adapter.closeSession).not.toHaveBeenCalled() + rig.clock.now += IDLE_MS + 1 + await vi.waitFor(() => expect(rig.adapter.closeSession).toHaveBeenCalledWith(SESSION)) + }) + it('keeps a child an unanswered prompt waits on (P2-22 i)', async () => { await foundRestTestChat(rig) providerEvents().appendItem( @@ -300,6 +317,7 @@ describe('the idle sweep with no child running (P2-22 ii)', () => { deliveryActive: () => false, backgroundTaskState: () => undefined, hasOpenDispatch: () => false, + providerHoldsDispatch: () => false, stopAgent, stopStartingAgent: stopAgent, closeConversation, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 2394b604171c..04e5744ff639 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -30,6 +30,8 @@ export type StructuredAgentSessionIdleSweepDeps = { backgroundTaskState: (sessionId: string) => AgentSessionBackgroundTaskState | null | undefined /** An orchestration dispatch that still owns this session's worker; derived from its database. */ hasOpenDispatch: (sessionId: string) => boolean + /** The provider holds a send it took and has not answered; see `holdsDispatch`. */ + providerHoldsDispatch: (sessionId: string) => boolean /** Each of these runs inside the session's serialize and never takes it again. */ stopAgent: (sessionId: string) => Promise stopStartingAgent: (sessionId: string) => Promise @@ -145,6 +147,7 @@ export class StructuredAgentSessionIdleSweep { this.queuedOrDelivering(sessionId, session) || agentChildWorkLiveness(this.deps.backgroundTaskState(sessionId)?.tasks) !== null || this.deps.hasOpenDispatch(sessionId) || + this.deps.providerHoldsDispatch(sessionId) || hasPendingStructuredAgentSessionPrompt(items) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 3ea116fa361d..105fffe941fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -43,6 +43,7 @@ export type RestTestAdapter = { NonNullable > backgroundTaskState: Mock> + holdsDispatch: Mock> readOptions: Mock> } @@ -147,6 +148,7 @@ export async function createRestTestRig( dispatch: vi.fn(async () => acceptedDispatch()), acknowledgeSessionRelease: vi.fn(), backgroundTaskState: vi.fn(() => undefined), + holdsDispatch: vi.fn(() => false), readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })) } const hostFor = (overrides: Partial) => diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index 8e6107dce97e..34b24787d966 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -143,6 +143,7 @@ describe('a live Claude chat whose CLI took a send and let it go without an echo expect(await submission(host, taken)).toMatchObject({ dispatchState: 'pending' }) child.handlers.onMessage?.(lifecycle(sentUuid, 'queued')) child.handlers.onMessage?.(lifecycle(sentUuid, 'started')) + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(true) child.handlers.onMessage?.( ending === 'idle' ? sessionState('idle') : lifecycle(sentUuid, ending) @@ -155,6 +156,7 @@ describe('a live Claude chat whose CLI took a send and let it go without an echo reason }) ) + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(false) // Released doubt: the chat stops reading working, and nothing re-sends it. expect( hasUnansweredStructuredAgentSessionDispatch((await host.journalSnapshot(SESSION)).submissions) From 86813491c7d78bcbd17f03b6bf3fc0df81d1f840 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:45:13 -0700 Subject: [PATCH 07/11] fix(claude): count only a started send at idle and as a held send 2.1.280's end-of-turn cleanup can report idle before it re-reads its queue, so a send read in that window goes queued, idle, started. Releasing every taken send at idle doubted that live send and dropped Working. Only a `started` send is released at idle or keeps the child from the idle sweep; a `queued` one ends by starting and echoing, by a terminal lifecycle frame, or with the child. The stdin-order path for CLIs without lifecycle frames is removed: a doubted send retired there disables content matching on CLIs that mint their own echo ids, and no Orca failure called for it. Those CLIs keep the earlier behaviour. Comments that said only a failed write or child exit ends a waiter, or that idle comes only once the queue has drained, now say what ends one. --- .../claude/claude-command-lifecycle.test.ts | 102 ++++++------------ src/main/claude/claude-command-lifecycle.ts | 16 ++- .../claude-structured-control-actions.ts | 9 -- .../claude-structured-dispatch-waiters.ts | 6 +- src/main/claude/claude-structured-dispatch.ts | 7 +- .../claude-structured-session-acquisition.ts | 2 +- .../claude/claude-structured-session-state.ts | 6 +- .../structured-agent-session-adapter.ts | 2 +- ...e-structured-idle-releases-unknown.test.ts | 4 +- 9 files changed, 52 insertions(+), 102 deletions(-) diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 9d6f58c39bfd..0c9a3da9f882 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -497,81 +497,49 @@ describe('a send the CLI holds', () => { expect(replay.adapter.holdsDispatch('session-1')).toBe(false) }) - it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( - 'holds nothing once the %s capture has run to its idle', - async (name) => { - const replay = await replayCapture(name) - - expect(replay.adapter.holdsDispatch('session-1')).toBe(false) - } - ) -}) - -describe('a Stop on a CLI that reports no lifecycle', () => { - const OLDER_CLI = ['msg_lifecycle_v1', 'interrupt_receipt_v1', 'interrupt_cancel_queued_v1'] - const withoutLifecycle = (frame: Record) => isLifecycleFrame(frame) - - it('releases the send the interrupt caught at the next idle, and never one written after it', async () => { - const replay = await replayCapture('interrupt-lost', { - withoutCapabilities: OLDER_CLI, - omitFrame: withoutLifecycle, - atControl: async (point) => { - // Armed ahead of the interrupt, but its write finishes after it. - const send = point.connection.send - const gated = Promise.withResolvers() - const reachedGate = Promise.withResolvers() - point.connection.send = async (message, beforeDispatch) => { - point.connection.send = send - await beforeDispatch?.() - reachedGate.resolve() - await gated.promise - point.connection.sent.push(message) - } - const unwritten = point.adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-D', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'D' }] }, - fence: 7 - }) - await reachedGate.promise - // An older CLI's interrupt answers with no receipt. - point.routes.interrupt = () => { - point.deliverInFlight() - return {} - } - await point.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) - gated.resolve() - await expect(unwritten).resolves.toEqual({ state: 'admitted' }) - await expect( - point.adapter.dispatch({ - sessionId: 'session-1', - clientMessageId: 'client-C', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'C' }] }, - fence: 7 - }) - ).resolves.toEqual({ state: 'admitted' }) - } + // 2.1.280's end-of-turn cleanup can report idle before it re-reads its queue. + it('leaves a queued send the CLI idles before starting to open its turn and be accepted', async () => { + const replay = await replayCapture('auth-failed', { + afterFrame: (frame) => + isLifecycleFrame(frame, 'queued') + ? [ + { + type: 'system', + subtype: 'session_state_changed', + state: 'idle', + uuid: 'idle-before-start', + session_id: frame.session_id + } + ] + : [] }) + const uuid = replay.liveUuid('client-A') - expect(replay.connection.calls.filter((call) => call.subtype === 'interrupt')).toEqual([ - { subtype: 'interrupt', params: {} } - ]) - expect(replay.settlementsFor('client-B')).toEqual([IDLE_IN_DOUBT]) - expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(replay.liveUuid('client-A'))]) - // Read after the interrupt, so the CLI may still run them. - expect(replay.settlementsFor('client-C')).toEqual([]) - expect(replay.settlementsFor('client-D')).toEqual([]) + expect(replay.settlementsFor('client-A')).toEqual([acceptedAs(uuid)]) + expect(replay.turnStates.get(uuid)).toBe('completed') }) - it('leaves a written send pending at idle when no interrupt came after it', async () => { - const replay = await replayCapture('interrupt-lost', { - withoutCapabilities: OLDER_CLI, - omitFrame: withoutLifecycle + it('does not hold the child for a send the CLI has only queued, nor release it at idle', async () => { + const replay = await replayCapture('auth-failed', { + omitFrame: (frame) => + isTurnOutput(frame) || + isLifecycleFrame(frame, 'started') || + isLifecycleFrame(frame, 'cancelled') }) expect(replay.idles).toEqual(['session-1']) - expect(replay.settlementsFor('client-B')).toEqual([]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + expect(replay.settlementsFor('client-A')).toEqual([]) }) + + it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( + 'holds nothing once the %s capture has run to its idle', + async (name) => { + const replay = await replayCapture(name) + + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + } + ) }) describe('the CLI reporting its session idle', () => { diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index 7f636724db6e..1336954b1f00 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -66,23 +66,19 @@ export function observeClaudeCommandLifecycle( } } -/** A send the CLI has taken and neither echoed nor ended: the idle sweep must not rest its child. */ +/** A send the CLI started and neither echoed nor ended: the idle sweep must not rest its child. */ export function claudeHoldsDispatch(session: ClaudeSession): boolean { - return session.dispatchWaiters.some((waiter) => waiter.commandLifecycle !== undefined) + return session.dispatchWaiters.some((waiter) => waiter.commandLifecycle === 'started') } /** - * `session_state_changed idle` comes only once the CLI's queue has drained, so a send it took that - * is still unanswered here left without an echo — a turn that threw can leave `started` with no - * terminal state. What proves it took one is its lifecycle frame or, on a CLI with none, its - * position on stdin: written ahead of an interrupt, it was read before the interrupt was. + * At idle, only a `started` send still unanswered is doubt: a turn that threw leaves it with no + * terminal state. A `queued` one may still start; the CLI can idle before it re-reads its queue. */ export function releaseClaudeDispatchesUnansweredAtIdle( session: ClaudeSession, onSettledLate?: ClaudeLateDispatchSettlement ): void { - const taken = session.dispatchWaiters.filter( - (waiter) => waiter.commandLifecycle !== undefined || waiter.writtenBeforeInterrupt - ) - releaseClaudeDispatchWaitersInDoubt(session, taken, DISPATCH_DOUBT_PROVIDER_IDLE, onSettledLate) + const started = session.dispatchWaiters.filter((waiter) => waiter.commandLifecycle === 'started') + releaseClaudeDispatchWaitersInDoubt(session, started, DISPATCH_DOUBT_PROVIDER_IDLE, onSettledLate) } diff --git a/src/main/claude/claude-structured-control-actions.ts b/src/main/claude/claude-structured-control-actions.ts index 3ffdcb7eb737..8ec1aa2e9c41 100644 --- a/src/main/claude/claude-structured-control-actions.ts +++ b/src/main/claude/claude-structured-control-actions.ts @@ -6,7 +6,6 @@ import type { ClaudeLateDispatchSettlement } from './claude-replay-turn-resoluti import type { ClaudeSession } from './claude-structured-session-state' const INTERRUPT_CANCEL_QUEUED_CAPABILITY = 'interrupt_cancel_queued_v1' -const COMMAND_LIFECYCLE_CAPABILITY = 'msg_lifecycle_v1' export function supportsClaudeQueuedInterruptCancellation(session: ClaudeSession): boolean { return session.capabilities.includes(INTERRUPT_CANCEL_QUEUED_CAPABILITY) @@ -34,14 +33,6 @@ export async function cancelClaudeTurn( return { cancelled: false } } const cancelQueued = supportsClaudeQueuedInterruptCancellation(session) - // With no lifecycle frames, stdin order is the proof: the CLI reads these before the interrupt. - if (!session.capabilities.includes(COMMAND_LIFECYCLE_CAPABILITY)) { - for (const waiter of session.dispatchWaiters) { - if (waiter.written) { - waiter.writtenBeforeInterrupt = true - } - } - } try { const receipt = await session.connection.interrupt({ ...(cancelQueued ? { cancelQueued: true } : {}), diff --git a/src/main/claude/claude-structured-dispatch-waiters.ts b/src/main/claude/claude-structured-dispatch-waiters.ts index 92b6a5048b2f..2c5df3c671eb 100644 --- a/src/main/claude/claude-structured-dispatch-waiters.ts +++ b/src/main/claude/claude-structured-dispatch-waiters.ts @@ -13,8 +13,10 @@ export function forgetRetiredWaiter(session: ClaudeSession, waiter: ClaudeDispat * A waiter with no deadline. A mid-turn send Claude folds into the running turn * is replayed mid-turn; one it runs later is replayed only when its own turn * starts — an interval bounded only by the previous turn. Elapsed time is - * therefore not evidence about delivery, and nothing here expires. Waiters are - * retired by process facts instead: a failed write, or child exit. + * therefore not evidence about delivery, and nothing here expires. Waiters end + * by provider and process facts instead: an echo, a `command_lifecycle` frame, + * the CLI's idle after `started` (`claude-command-lifecycle.ts`), a failed + * write, or child exit. */ export function waitForReplay( session: ClaudeSession, diff --git a/src/main/claude/claude-structured-dispatch.ts b/src/main/claude/claude-structured-dispatch.ts index 18cb735e5837..d98ad4119ecd 100644 --- a/src/main/claude/claude-structured-dispatch.ts +++ b/src/main/claude/claude-structured-dispatch.ts @@ -95,8 +95,8 @@ export function releaseClaudeDispatchWaitersInDoubt( } } -/** Nothing expires a waiter, so the child's death is what ends every live one. - * Retired rather than dropped: their identities stay joinable, bounded by +/** Nothing expires a waiter; the child's death ends every one its echo, a lifecycle frame or the + * CLI's idle has not. Retired rather than dropped: their identities stay joinable, bounded by * `MAX_RETIRED_DISPATCH_WAITERS`. */ export function retireClaudeDispatchWaiters(session: ClaudeSession): void { failClaudeStartup(session, new Error('claude stream-json ended before startup completed')) @@ -200,9 +200,6 @@ export async function dispatchClaudeTurn( } return { state: 'unknown', reason: dispatchWriteOutcomeUnknownReason(error) } } - if (pending.replay) { - pending.replay.waiter.written = true - } // The write is the admission signal. Awaiting the echo here would block on the // turn already running, which is why the deadline this replaces kept declaring // doubt about messages that were delivered. The replay resolution diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index b4adbfccfa5c..0a78e270ee10 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -118,7 +118,7 @@ export async function acquireClaudeSession({ if (message.type === 'result' && sessions.get(sessionId) === liveSession) { persistClaudeTurnResumePoint(sessionId, liveSession, deps) } - // The CLI idles only once its queue drains, so it holds none of this session's sends. + // The CLI's idle re-derives the doubted sends it can no longer answer. if (claudeSessionStateEndsTurn(message) && sessions.get(sessionId) === liveSession) { deps.onSessionIdle?.({ sessionId }) } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index fd3b0ef351a6..29b7594fb9d9 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -97,7 +97,7 @@ export type ClaudeStructuredSessionAdapterDeps = { onEvent?: (event: ClaudeStructuredSessionEvent) => void /** Direct settlement path for provider-proven late dispatch outcomes. */ onDispatchSettledLate?: (input: { sessionId: string } & ClaudeLateDispatchOutcome) => void - /** The CLI reported `session_state_changed idle`, which it sends only once its queue drains. */ + /** The CLI reported `session_state_changed idle`: its turn is over. */ onSessionIdle?: (input: { sessionId: string }) => void onBackgroundTasksChanged?: ( sessionId: string, @@ -145,10 +145,6 @@ export type ClaudeDispatchWaiter = { retired?: boolean /** The CLI's last non-terminal `command_lifecycle` state for this send; in memory only. */ commandLifecycle?: 'queued' | 'started' - /** The SDK finished writing this send to the CLI's stdin. */ - written?: true - /** Written ahead of an interrupt, so a CLI that reports no lifecycle has read it by its idle. */ - writtenBeforeInterrupt?: true /** Bounded digest/summary for compatibility CLIs that mint UUIDs. */ replayContentKey: string } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index df480721202f..db27f972dbc6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -315,7 +315,7 @@ export type StructuredAgentSessionAdapter = { taskId?: string }): Promise<{ cancelled: boolean }> backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined - /** The provider reported taking a send it has neither answered nor ended, as a silent retry + /** The provider reported starting a send it has neither answered nor ended, as a silent retry * does. Derived from the live child; false with none. */ holdsDispatch?(sessionId: string): boolean /** The `/` surface the running provider reports for itself. Undefined when the diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index 34b24787d966..5a03c505826a 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -1,6 +1,6 @@ // A Claude send whose write ended in doubt is recorded `unknown`, and a live `unknown` reads as -// work still owed. The CLI reports `session_state_changed idle` only once its queue has drained, -// so that report retires the doubt; a send whose dispatch is still `pending` is left alone. +// work still owed. The CLI reporting `session_state_changed idle` retires the doubt; a send whose +// dispatch is still `pending` is left alone unless the CLI had started it. // Against the production runtime, adapter, record store and host, with only the CLI scripted. import { afterEach, describe, expect, it, vi } from 'vitest' From a71b3ecdde6b30379bf0c61662909804e733c617 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:50:07 -0700 Subject: [PATCH 08/11] docs(claude): say only what the CLI's lifecycle frames and idle actually prove --- src/main/claude/claude-command-lifecycle.ts | 8 ++++---- src/main/claude/claude-structured-session-acquisition.ts | 2 +- src/main/claude/claude-structured-session-state.ts | 4 ++-- ...tructured-agent-session-unanswered-dispatch-release.ts | 2 +- src/main/runtime/structured-agent-session-runtime.ts | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index 1336954b1f00..bd985d64fe08 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -2,9 +2,9 @@ // // A terminal state for a command that never started is a verdict: `cancelled` was withdrawn, // `discarded` was dropped when the CLI ended its session, `refused` was declined before it -// queued. None ran. Once a command has started, the same states only mean its turn ended — an -// interrupt or a hard failure (measured on 2.1.280) — so it may be in the conversation: doubt. -// These frames land ahead of the control answer, so they settle a send even when that is lost. +// queued. None ran. After `started`, 2.1.280 sends only `cancelled`, when an interrupt or a hard +// failure ends the turn (measured), so the send may be in the conversation: any end is doubt. +// An unstarted send's `cancelled` lands before the Stop's answer, so it settles if that is lost. import { agentSessionFailureFact, @@ -73,7 +73,7 @@ export function claudeHoldsDispatch(session: ClaudeSession): boolean { /** * At idle, only a `started` send still unanswered is doubt: a turn that threw leaves it with no - * terminal state. A `queued` one may still start; the CLI can idle before it re-reads its queue. + * terminal state. A `queued` one may still start: 2.1.280's code idles before re-reading its queue. */ export function releaseClaudeDispatchesUnansweredAtIdle( session: ClaudeSession, diff --git a/src/main/claude/claude-structured-session-acquisition.ts b/src/main/claude/claude-structured-session-acquisition.ts index 0a78e270ee10..0a160d99b934 100644 --- a/src/main/claude/claude-structured-session-acquisition.ts +++ b/src/main/claude/claude-structured-session-acquisition.ts @@ -118,7 +118,7 @@ export async function acquireClaudeSession({ if (message.type === 'result' && sessions.get(sessionId) === liveSession) { persistClaudeTurnResumePoint(sessionId, liveSession, deps) } - // The CLI's idle re-derives the doubted sends it can no longer answer. + // The CLI's idle releases its doubted sends; a late echo still accepts one it goes on to run. if (claudeSessionStateEndsTurn(message) && sessions.get(sessionId) === liveSession) { deps.onSessionIdle?.({ sessionId }) } diff --git a/src/main/claude/claude-structured-session-state.ts b/src/main/claude/claude-structured-session-state.ts index 29b7594fb9d9..ba7432f85452 100644 --- a/src/main/claude/claude-structured-session-state.ts +++ b/src/main/claude/claude-structured-session-state.ts @@ -141,7 +141,7 @@ export type ClaudeDispatchWaiter = { requestedAt: number | null /** Set when the provider replay settled this waiter before send returned. */ settledUuid?: string - /** The write failed or the child died, but a replay may still name it. */ + /** Settled with no echo (failed write, child exit, doubt after start); a replay may name it. */ retired?: boolean /** The CLI's last non-terminal `command_lifecycle` state for this send; in memory only. */ commandLifecycle?: 'queued' | 'started' @@ -160,7 +160,7 @@ export type ClaudeSession = { acquisitionGeneration: string prompts: ClaudePromptRegistry dispatchWaiters: ClaudeDispatchWaiter[] - /** Bounded identities for dispatches whose child died or whose write failed. */ + /** Bounded identities for dispatches settled without an echo; a late replay still accepts one. */ retiredDispatchWaiters: ClaudeDispatchWaiter[] /** Once a retired waiter is evicted, legacy content-only replay matching is unsafe. */ replayContentFallbackBlocked: boolean diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts index 713838c177ec..62078ef853c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-unanswered-dispatch-release.ts @@ -3,7 +3,7 @@ import type { StructuredAgentSessionHostDeps } from './structured-agent-session- import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' /** - * Releases sends the provider can no longer be holding. + * Releases doubted sends once the provider reports it has stopped running. * * A dispatch whose RPC timed out is recorded `unknown` — doubt, never proof of * non-delivery — and a live `unknown` reads as work still owed, so the session diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 10db8ba3bda4..9d1e4eeecce1 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -215,7 +215,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { void host ?.releaseUnansweredDispatches({ From 6f2c729c1a4bacd9159e06958ab558b9bf7d7191 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:11:30 -0700 Subject: [PATCH 09/11] fix(claude): hold the idle sweep while Claude has a send queued, not only started The sweep rested a child whose CLI had queued a follow-up behind a turn, dropping the send it had already taken. The hold now spans the CLI reporting it took the send until its echo, a terminal lifecycle state, or the child's exit. The idle release still covers only started sends: 2.1.280 can report idle before it re-reads its queue. --- .../claude/claude-command-lifecycle.test.ts | 37 ++++++++++++++++++- src/main/claude/claude-command-lifecycle.ts | 4 +- .../structured-agent-session-adapter.ts | 4 +- ...e-structured-idle-releases-unknown.test.ts | 26 +++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index 0c9a3da9f882..a1c83ceb1017 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -519,7 +519,7 @@ describe('a send the CLI holds', () => { expect(replay.turnStates.get(uuid)).toBe('completed') }) - it('does not hold the child for a send the CLI has only queued, nor release it at idle', async () => { + it('holds the child for a send the CLI has only queued, and does not release it at idle', async () => { const replay = await replayCapture('auth-failed', { omitFrame: (frame) => isTurnOutput(frame) || @@ -528,10 +528,43 @@ describe('a send the CLI holds', () => { }) expect(replay.idles).toEqual(['session-1']) - expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + expect(replay.adapter.holdsDispatch('session-1')).toBe(true) expect(replay.settlementsFor('client-A')).toEqual([]) }) + it('holds a queued send until the CLI withdraws it', async () => { + const held: boolean[] = [] + const replay = await replayCapture('cancel-async', { + atControl: async (point) => { + // The lead was echoed already; only the queued follow-up is left. + held.push(point.adapter.holdsDispatch('session-1')) + point.deliverInFlight() + held.push(point.adapter.holdsDispatch('session-1')) + } + }) + + expect(held).toEqual([true, false]) + expect(replay.settlementsFor('client-B')).toEqual([WITHDRAWN]) + }) + + it('holds a queued send until it starts and is echoed', async () => { + const held: boolean[] = [] + const replay = await replayCapture('batch-lead', { + // Without its turn's end or the idle, only the start and echo can let it go. + omitFrame: (frame) => + frame.type === 'result' || isLifecycleFrame(frame, 'completed') || isIdleFrame(frame), + atControl: async (point) => { + point.deliverInFlight() + // The batch lead is withdrawn; the send behind it is only queued. + held.push(point.adapter.holdsDispatch('session-1')) + } + }) + + expect(held).toEqual([true]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + expect(replay.settlementsFor('client-C')).toEqual([acceptedAs(replay.liveUuid('client-C'))]) + }) + it.each(['interrupt-lost', 'cancel-async', 'batch-lead', 'auth-failed'])( 'holds nothing once the %s capture has run to its idle', async (name) => { diff --git a/src/main/claude/claude-command-lifecycle.ts b/src/main/claude/claude-command-lifecycle.ts index bd985d64fe08..a5d4408799bd 100644 --- a/src/main/claude/claude-command-lifecycle.ts +++ b/src/main/claude/claude-command-lifecycle.ts @@ -66,9 +66,9 @@ export function observeClaudeCommandLifecycle( } } -/** A send the CLI started and neither echoed nor ended: the idle sweep must not rest its child. */ +/** A send the CLI took, until its echo, a terminal state or exit: the sweep must not rest its child. */ export function claudeHoldsDispatch(session: ClaudeSession): boolean { - return session.dispatchWaiters.some((waiter) => waiter.commandLifecycle === 'started') + return session.dispatchWaiters.some((waiter) => waiter.commandLifecycle !== undefined) } /** diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts index 28fd59e35d60..e1f41ed9fb58 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adapter.ts @@ -334,8 +334,8 @@ export type StructuredAgentSessionAdapter = { taskId?: string }): Promise<{ cancelled: boolean }> backgroundTaskState?(sessionId: string): AgentSessionBackgroundTaskState | null | undefined - /** The provider reported starting a send it has neither answered nor ended, as a silent retry - * does. Derived from the live child; false with none. */ + /** The provider reported taking a send it has neither answered nor ended, as a queued follow-up + * or a silent retry does. Derived from the live child; false with none. */ holdsDispatch?(sessionId: string): boolean /** The `/` surface the running provider reports for itself. Undefined when the * provider never reports one, which is what keeps the client on its catalog. */ diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index 5a03c505826a..9fa9cbfcfa6a 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -162,4 +162,30 @@ describe('a live Claude chat whose CLI took a send and let it go without an echo hasUnansweredStructuredAgentSessionDispatch((await host.journalSnapshot(SESSION)).submissions) ).toBe(false) }) + + it('holds a send it has only queued, through an idle that may precede its start', async () => { + const host = await claude.install() + await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + ok: true + }) + const child = claude.child(SESSION) + let sentUuid = '' + child.connection.send = async (message, beforeDispatch) => { + await beforeDispatch?.() + sentUuid = String(message.uuid) + } + const queued = await send(host, 'behind a turn') + await vi.waitFor(() => expect(sentUuid).not.toBe('')) + // POSITIVE CONTROL: written but not yet taken, nothing holds the child. + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(false) + + child.handlers.onMessage?.(lifecycle(sentUuid, 'queued')) + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(true) + child.handlers.onMessage?.(sessionState('idle')) + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(host.deps.adapter.holdsDispatch?.(SESSION)).toBe(true) + expect(await submission(host, queued)).toMatchObject({ dispatchState: 'pending' }) + expect((await submission(host, queued))?.recovered).toBeUndefined() + }) }) From ff9d0a45ec3874a487a8b3f3834205d929ce8725 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:30:01 -0700 Subject: [PATCH 10/11] refactor(native-chat): give provider-proven late dispatch settlement its own module --- ...structured-agent-session-host-mutations.ts | 56 +------------------ .../structured-agent-session-host.ts | 2 +- .../structured-agent-session-late-dispatch.ts | 49 ++++++++++++++++ 3 files changed, 52 insertions(+), 55 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 1c5dac3a517f..1b729ad62b8e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -7,10 +7,7 @@ // admitted without the writer lease; the delivery loop starts the provider child a send needs, and // an operation only the provider can perform starts it before admission. -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, AgentSessionMutationEnvelope, @@ -22,14 +19,10 @@ import type { AgentSessionThreadGoalResult } from '../../../shared/agent-session-wire' import { isStructuredAgentSessionMainAgentWorking } from '../../../shared/structured-agent-session-main-agent-working' -import { - agentSessionFailureWords, - type AgentJournalDispatchRejection -} from '../../../shared/agent-session-failure-words' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { threadGoalPlan } from './structured-agent-session-thread-goal' -import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { admitAndRunAgentSessionMutation, type AgentSessionMutationRequest, @@ -279,51 +272,6 @@ export function changeStructuredAgentSessionThreadGoal( ) } -/** Settle a provider-proven outcome independently of an in-flight client mutation. `unknown` is - * released doubt: the provider let the send go unanswered, so nothing re-sends it. */ -export async function settleStructuredAgentSessionLateDispatch( - context: StructuredAgentSessionMutationContext, - input: { - sessionId: string - clientMessageId: string - } & ( - | { providerIdentity: AgentJournalItemIdentity } - | ({ state: 'rejected' } & AgentJournalDispatchRejection) - | { state: 'unknown'; reason: string } - ) -): Promise { - const session = context.sessions.get(input.sessionId) - if (!session) { - return - } - const fence = structuredAgentSessionConversationFence(context.deps.store, input.sessionId) - // The journal queue drains before close; the host queue would defer this past teardown. - await session.journal.resolveDispatch( - 'providerIdentity' in input - ? { - clientMessageId: input.clientMessageId, - state: 'accepted', - providerIdentity: input.providerIdentity, - fence - } - : input.state === 'unknown' - ? { - clientMessageId: input.clientMessageId, - state: 'unknown', - reason: input.reason, - fence, - recovered: true - } - : { - clientMessageId: input.clientMessageId, - state: 'rejected', - reason: input.reason, - rejection: input.rejection, - fence - } - ) -} - /** The host's thin mutation surface. Each call re-reads the context, so a session * map or fence that moves between calls is never captured by a stale closure. */ export function structuredAgentSessionMutationDelegates( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index efdd83d9f146..6737f7ace002 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -34,9 +34,9 @@ import type { StructuredAgentSessionAttachContext } from './structured-agent-ses import * as sessionTabs from './structured-agent-session-host-tabs' import { structuredAgentSessionMutationDelegates, - settleStructuredAgentSessionLateDispatch, type StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { settleStructuredAgentSessionLateDispatch } from './structured-agent-session-late-dispatch' import { releaseStructuredAgentSessionUnansweredDispatches } from './structured-agent-session-unanswered-dispatch-release' import { flushStructuredAgentSessionHost } from './structured-agent-session-host-teardown' import type { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts new file mode 100644 index 000000000000..ba0ab9231261 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-dispatch.ts @@ -0,0 +1,49 @@ +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' +import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' + +/** Settle a provider-proven outcome independently of an in-flight client mutation. `unknown` is + * released doubt: the provider let the send go unanswered, so nothing re-sends it. */ +export async function settleStructuredAgentSessionLateDispatch( + context: StructuredAgentSessionMutationContext, + input: { + sessionId: string + clientMessageId: string + } & ( + | { providerIdentity: AgentJournalItemIdentity } + | ({ state: 'rejected' } & AgentJournalDispatchRejection) + | { state: 'unknown'; reason: string } + ) +): Promise { + const session = context.sessions.get(input.sessionId) + if (!session) { + return + } + const fence = structuredAgentSessionConversationFence(context.deps.store, input.sessionId) + // The journal queue drains before close; the host queue would defer this past teardown. + await session.journal.resolveDispatch( + 'providerIdentity' in input + ? { + clientMessageId: input.clientMessageId, + state: 'accepted', + providerIdentity: input.providerIdentity, + fence + } + : input.state === 'unknown' + ? { + clientMessageId: input.clientMessageId, + state: 'unknown', + reason: input.reason, + fence, + recovered: true + } + : { + clientMessageId: input.clientMessageId, + state: 'rejected', + reason: input.reason, + rejection: input.rejection, + fence + } + ) +} From 5471ca443bf9506b85399190c05a68c5ee2727d5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:37:49 -0700 Subject: [PATCH 11/11] test(claude): pin a steer a Stop interrupts after it started as doubt, not withdrawn --- .../claude/claude-command-lifecycle.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/src/main/claude/claude-command-lifecycle.test.ts b/src/main/claude/claude-command-lifecycle.test.ts index a1c83ceb1017..f4182a601747 100644 --- a/src/main/claude/claude-command-lifecycle.test.ts +++ b/src/main/claude/claude-command-lifecycle.test.ts @@ -451,6 +451,52 @@ describe('a send the CLI started, then cancelled', () => { ) }) +// batch-lead's client-C is a steer: written while A's turn runs, queued, then started as its own +// turn once A completed. Its queued-while-Stopped case is interrupt-lost's client-B, above. +describe('a steer a Stop interrupts after it started', () => { + it('is released as doubt, never withdrawn, when the Stop lands before its echo', async () => { + const steer = capturedUuid('batch-lead', 'client-C') + let started = false + let routes: ControlPoint['routes'] | undefined + const replay = await replayCapture('batch-lead', { + atControl: async (point) => { + routes = point.routes + point.deliverInFlight() + }, + // The Stop lands between the steer's start and its echo: nothing after its start arrives. + omitFrame: () => started, + afterFrame: (frame) => { + started ||= isLifecycleFrame(frame, 'started') && frame.command_uuid === steer + return [] + } + }) + const uuid = replay.liveUuid('client-C') + expect(replay.adapter.holdsDispatch('session-1')).toBe(true) + + // Answered as interrupt-lost's was, with nothing left queued; then that capture's tail for a + // started command a Stop ended (its result dropped: one naming the steer would accept it). + routes!.interrupt = () => ({ still_queued: [], cancelled: [] }) + await replay.adapter.cancelTurn({ sessionId: 'session-1', fence: 7 }) + for (const frame of [ + { + type: 'command_lifecycle', + command_uuid: uuid, + state: 'cancelled', + uuid: 'steer-cancelled' + }, + { type: 'system', subtype: 'session_state_changed', state: 'idle', uuid: 'steer-idle' } + ]) { + replay.connection.handlers.onMessage?.({ ...frame, session_id: PROVIDER_SESSION_ID }) + } + + expect(replay.connection.calls.filter((call) => call.subtype === 'interrupt')).toEqual([ + { subtype: 'interrupt', params: { cancelQueued: true } } + ]) + expect(replay.settlementsFor('client-C')).toEqual([ENDED_IN_DOUBT]) + expect(replay.adapter.holdsDispatch('session-1')).toBe(false) + }) +}) + describe('a send the CLI ended before starting it', () => { it.each([ // The CLI ended its session with the send still queued.