From 25c5c1d9f8a59acb7cff722ed2725c4e5f9f6b40 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 06:52:07 +0000 Subject: [PATCH 01/36] feat(db): add Phase 2 CLI collaboration state --- Cargo.lock | 1 + ...e8c0706c7e2271eefab440500f10b8a6910fa.json | 12 + ...a84d85b80ad65dba417b5115dbd02562737bc.json | 12 + ...1330b00fcc29b0d064eac4fc5a01c68a024a.json} | 12 +- ...76cb3bc30e55e3de4fcd48f26b4946890517c.json | 12 + ...e2aa8bdeb323f84ad6ee3041336357c858fa3.json | 12 + ...6b348cc0cc61c2a482410494dd3411b3113ce.json | 12 + ...2a58f6daed273419595b9f3f17b37019446ad.json | 12 + ...4909ebcf61ae21ae5b845e5ccb4a85ec094cb.json | 12 + ...1978ae11f79c1bde13febfc9d619cbe7fcbc9.json | 12 + ...1c73e1d9eebf058e7447bea9d43bb9bb91caf.json | 12 + ...0c01323ad474e59e469db9638bb1d427685f3.json | 12 + ...c3579ceca4fc4137d88b5626977420aaef56e.json | 12 + ...0101a752aa2b6efe8fe33f080ef33b9a016de.json | 12 + ...cfca531992f452301016f0f1618d67f17c09c.json | 12 + ...b9d85febfee88931db06d1757836165e9e35c.json | 12 + ...e4c748ed245a65c01c1c34e7076d05fda6c68.json | 12 + ...510f19c64508c80cf42344ee563cf6d5a6c18.json | 44 ++ ...4c8141cfd2800a7ca34e8ea1c777a29d42ac.json} | 18 +- ...6093cf67bc7ad9912047ab2c23c7bc95d251f.json | 12 + ...2c7f0bce031e5ba46a103967853295256b7a3.json | 12 + crates/db/Cargo.toml | 3 + ...0721090000_add_session_queued_messages.sql | 29 + ...dd_cli_delivery_ack_and_foreign_writer.sql | 10 + .../20260721092000_add_cli_pane_bindings.sql | 20 + ...93000_add_workspace_spawn_reservations.sql | 11 + crates/db/src/models/claude_session_link.rs | 4 +- crates/db/src/models/cli_native_record.rs | 3 + crates/db/src/models/cli_pane_binding.rs | 149 +++++ crates/db/src/models/mod.rs | 3 + .../db/src/models/session_queued_message.rs | 545 ++++++++++++++++++ .../src/models/workspace_spawn_reservation.rs | 192 ++++++ 32 files changed, 1238 insertions(+), 10 deletions(-) create mode 100644 crates/db/.sqlx/query-0cae1add79c3f27f8bf6a7dd9a2e8c0706c7e2271eefab440500f10b8a6910fa.json create mode 100644 crates/db/.sqlx/query-16f187e3600311ad7502f6034f6a84d85b80ad65dba417b5115dbd02562737bc.json rename crates/db/.sqlx/{query-8141e9e690983c2a94318c06b8525f8290db122cbb6f2bbefb2bace0bbe98ebf.json => query-1900e82c2e1bc3b80dffc3c652ad1330b00fcc29b0d064eac4fc5a01c68a024a.json} (71%) create mode 100644 crates/db/.sqlx/query-258c9f617b90b017353de11caf176cb3bc30e55e3de4fcd48f26b4946890517c.json create mode 100644 crates/db/.sqlx/query-496ca440002e51e10cb979ae4e7e2aa8bdeb323f84ad6ee3041336357c858fa3.json create mode 100644 crates/db/.sqlx/query-4f7ac2ea943df6facd5d22bc88f6b348cc0cc61c2a482410494dd3411b3113ce.json create mode 100644 crates/db/.sqlx/query-571ac8dbfea3f9fe034b21df5082a58f6daed273419595b9f3f17b37019446ad.json create mode 100644 crates/db/.sqlx/query-5a48e6d609556ff6fa84a5822da4909ebcf61ae21ae5b845e5ccb4a85ec094cb.json create mode 100644 crates/db/.sqlx/query-6112db769dea7cfee0deb4644b11978ae11f79c1bde13febfc9d619cbe7fcbc9.json create mode 100644 crates/db/.sqlx/query-6c3bad4405429bf3b9ec81b7c6c1c73e1d9eebf058e7447bea9d43bb9bb91caf.json create mode 100644 crates/db/.sqlx/query-6dacb2e53db643ffd3c0b33f0e60c01323ad474e59e469db9638bb1d427685f3.json create mode 100644 crates/db/.sqlx/query-6f7cfc58051988c9d05003dba3fc3579ceca4fc4137d88b5626977420aaef56e.json create mode 100644 crates/db/.sqlx/query-7dc08c213e156cb7d1bd2396d120101a752aa2b6efe8fe33f080ef33b9a016de.json create mode 100644 crates/db/.sqlx/query-8c1aa81a8ed2e05fa2074fdd427cfca531992f452301016f0f1618d67f17c09c.json create mode 100644 crates/db/.sqlx/query-953a08426ac82ecb9b581115fb7b9d85febfee88931db06d1757836165e9e35c.json create mode 100644 crates/db/.sqlx/query-9ade237b8273f60e0acb6c1de93e4c748ed245a65c01c1c34e7076d05fda6c68.json create mode 100644 crates/db/.sqlx/query-a1c5617fb090de7823da7297966510f19c64508c80cf42344ee563cf6d5a6c18.json rename crates/db/.sqlx/{query-7bee6850aa473a3b541fd008c37c7ba0cbcbe16452817a7797361f2e21f180d7.json => query-a36b90635fba777c0863cc9afec44c8141cfd2800a7ca34e8ea1c777a29d42ac.json} (67%) create mode 100644 crates/db/.sqlx/query-cde081fce13f5de497bb5f4b22e6093cf67bc7ad9912047ab2c23c7bc95d251f.json create mode 100644 crates/db/.sqlx/query-ce4e0dac1ea70528f91d71049432c7f0bce031e5ba46a103967853295256b7a3.json create mode 100644 crates/db/migrations/20260721090000_add_session_queued_messages.sql create mode 100644 crates/db/migrations/20260721091000_add_cli_delivery_ack_and_foreign_writer.sql create mode 100644 crates/db/migrations/20260721092000_add_cli_pane_bindings.sql create mode 100644 crates/db/migrations/20260721093000_add_workspace_spawn_reservations.sql create mode 100644 crates/db/src/models/cli_pane_binding.rs create mode 100644 crates/db/src/models/session_queued_message.rs create mode 100644 crates/db/src/models/workspace_spawn_reservation.rs diff --git a/Cargo.lock b/Cargo.lock index bfff0d4729d..2ac55177f6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2029,6 +2029,7 @@ dependencies = [ "strum", "strum_macros 0.27.2", "thiserror 2.0.18", + "tokio", "tracing", "ts-rs 11.0.1", "utils", diff --git a/crates/db/.sqlx/query-0cae1add79c3f27f8bf6a7dd9a2e8c0706c7e2271eefab440500f10b8a6910fa.json b/crates/db/.sqlx/query-0cae1add79c3f27f8bf6a7dd9a2e8c0706c7e2271eefab440500f10b8a6910fa.json new file mode 100644 index 00000000000..c4783015cbc --- /dev/null +++ b/crates/db/.sqlx/query-0cae1add79c3f27f8bf6a7dd9a2e8c0706c7e2271eefab440500f10b8a6910fa.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO cli_pane_bindings\n (id, workspace_id, session_id, claude_session_id, bound_via)\n VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "0cae1add79c3f27f8bf6a7dd9a2e8c0706c7e2271eefab440500f10b8a6910fa" +} diff --git a/crates/db/.sqlx/query-16f187e3600311ad7502f6034f6a84d85b80ad65dba417b5115dbd02562737bc.json b/crates/db/.sqlx/query-16f187e3600311ad7502f6034f6a84d85b80ad65dba417b5115dbd02562737bc.json new file mode 100644 index 00000000000..f381c05e41a --- /dev/null +++ b/crates/db/.sqlx/query-16f187e3600311ad7502f6034f6a84d85b80ad65dba417b5115dbd02562737bc.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'imported',\n acked_at = COALESCE(acked_at, datetime('now', 'subsec')),\n updated_at = datetime('now', 'subsec')\n WHERE state IN ('pasting', 'pasted')\n AND EXISTS (\n SELECT 1 FROM cli_native_records record\n WHERE record.bound_queued_message_id = session_queued_messages.id\n )", + "describe": { + "columns": [], + "parameters": { + "Right": 0 + }, + "nullable": [] + }, + "hash": "16f187e3600311ad7502f6034f6a84d85b80ad65dba417b5115dbd02562737bc" +} diff --git a/crates/db/.sqlx/query-8141e9e690983c2a94318c06b8525f8290db122cbb6f2bbefb2bace0bbe98ebf.json b/crates/db/.sqlx/query-1900e82c2e1bc3b80dffc3c652ad1330b00fcc29b0d064eac4fc5a01c68a024a.json similarity index 71% rename from crates/db/.sqlx/query-8141e9e690983c2a94318c06b8525f8290db122cbb6f2bbefb2bace0bbe98ebf.json rename to crates/db/.sqlx/query-1900e82c2e1bc3b80dffc3c652ad1330b00fcc29b0d064eac4fc5a01c68a024a.json index 7cf5ebc1eaf..8e8a4cf4cf2 100644 --- a/crates/db/.sqlx/query-8141e9e690983c2a94318c06b8525f8290db122cbb6f2bbefb2bace0bbe98ebf.json +++ b/crates/db/.sqlx/query-1900e82c2e1bc3b80dffc3c652ad1330b00fcc29b0d064eac4fc5a01c68a024a.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT claude_session_id AS \"claude_session_id!\",\n session_id AS \"session_id!: Uuid\",\n workspace_id AS \"workspace_id!: Uuid\",\n cwd AS \"cwd!\",\n bound_via AS \"bound_via!: ClaudeSessionBoundVia\",\n created_at AS \"created_at!: DateTime\"\n FROM claude_session_links\n WHERE claude_session_id = $1", + "query": "SELECT claude_session_id AS \"claude_session_id!\",\n session_id AS \"session_id!: Uuid\",\n workspace_id AS \"workspace_id!: Uuid\",\n cwd AS \"cwd!\",\n bound_via AS \"bound_via!: ClaudeSessionBoundVia\",\n created_at AS \"created_at!: DateTime\",\n foreign_writer_seen_at AS \"foreign_writer_seen_at: DateTime\"\n FROM claude_session_links\n WHERE claude_session_id = $1", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "name": "created_at!: DateTime", "ordinal": 5, "type_info": "Text" + }, + { + "name": "foreign_writer_seen_at: DateTime", + "ordinal": 6, + "type_info": "Text" } ], "parameters": { @@ -43,8 +48,9 @@ false, false, false, - false + false, + true ] }, - "hash": "8141e9e690983c2a94318c06b8525f8290db122cbb6f2bbefb2bace0bbe98ebf" + "hash": "1900e82c2e1bc3b80dffc3c652ad1330b00fcc29b0d064eac4fc5a01c68a024a" } diff --git a/crates/db/.sqlx/query-258c9f617b90b017353de11caf176cb3bc30e55e3de4fcd48f26b4946890517c.json b/crates/db/.sqlx/query-258c9f617b90b017353de11caf176cb3bc30e55e3de4fcd48f26b4946890517c.json new file mode 100644 index 00000000000..9aef8ac1b6b --- /dev/null +++ b/crates/db/.sqlx/query-258c9f617b90b017353de11caf176cb3bc30e55e3de4fcd48f26b4946890517c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO session_queued_messages\n (id, session_id, prompt, executor_config, source, state)\n VALUES ($1, $2, $3, $4, $5, 'queued')", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "258c9f617b90b017353de11caf176cb3bc30e55e3de4fcd48f26b4946890517c" +} diff --git a/crates/db/.sqlx/query-496ca440002e51e10cb979ae4e7e2aa8bdeb323f84ad6ee3041336357c858fa3.json b/crates/db/.sqlx/query-496ca440002e51e10cb979ae4e7e2aa8bdeb323f84ad6ee3041336357c858fa3.json new file mode 100644 index 00000000000..435f0d1e56f --- /dev/null +++ b/crates/db/.sqlx/query-496ca440002e51e10cb979ae4e7e2aa8bdeb323f84ad6ee3041336357c858fa3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM workspace_spawn_reservations\n WHERE workspace_id = $1 AND fence = $2", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "496ca440002e51e10cb979ae4e7e2aa8bdeb323f84ad6ee3041336357c858fa3" +} diff --git a/crates/db/.sqlx/query-4f7ac2ea943df6facd5d22bc88f6b348cc0cc61c2a482410494dd3411b3113ce.json b/crates/db/.sqlx/query-4f7ac2ea943df6facd5d22bc88f6b348cc0cc61c2a482410494dd3411b3113ce.json new file mode 100644 index 00000000000..50a07f7c4b1 --- /dev/null +++ b/crates/db/.sqlx/query-4f7ac2ea943df6facd5d22bc88f6b348cc0cc61c2a482410494dd3411b3113ce.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'consumed',\n updated_at = datetime('now', 'subsec')\n WHERE id = $1 AND state = 'pasting'", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "4f7ac2ea943df6facd5d22bc88f6b348cc0cc61c2a482410494dd3411b3113ce" +} diff --git a/crates/db/.sqlx/query-571ac8dbfea3f9fe034b21df5082a58f6daed273419595b9f3f17b37019446ad.json b/crates/db/.sqlx/query-571ac8dbfea3f9fe034b21df5082a58f6daed273419595b9f3f17b37019446ad.json new file mode 100644 index 00000000000..f69279ce9a9 --- /dev/null +++ b/crates/db/.sqlx/query-571ac8dbfea3f9fe034b21df5082a58f6daed273419595b9f3f17b37019446ad.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n prompt = $1,\n executor_config = $2,\n source = $3,\n failure_reason = NULL,\n claude_session_id = NULL,\n pasted_at = NULL,\n acked_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $4 AND state = 'queued'", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "571ac8dbfea3f9fe034b21df5082a58f6daed273419595b9f3f17b37019446ad" +} diff --git a/crates/db/.sqlx/query-5a48e6d609556ff6fa84a5822da4909ebcf61ae21ae5b845e5ccb4a85ec094cb.json b/crates/db/.sqlx/query-5a48e6d609556ff6fa84a5822da4909ebcf61ae21ae5b845e5ccb4a85ec094cb.json new file mode 100644 index 00000000000..f84d2237068 --- /dev/null +++ b/crates/db/.sqlx/query-5a48e6d609556ff6fa84a5822da4909ebcf61ae21ae5b845e5ccb4a85ec094cb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = $1,\n claude_session_id = NULL,\n pasted_at = NULL,\n acked_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $2 AND state IN ('pasting', 'pasted')", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "5a48e6d609556ff6fa84a5822da4909ebcf61ae21ae5b845e5ccb4a85ec094cb" +} diff --git a/crates/db/.sqlx/query-6112db769dea7cfee0deb4644b11978ae11f79c1bde13febfc9d619cbe7fcbc9.json b/crates/db/.sqlx/query-6112db769dea7cfee0deb4644b11978ae11f79c1bde13febfc9d619cbe7fcbc9.json new file mode 100644 index 00000000000..7602787b343 --- /dev/null +++ b/crates/db/.sqlx/query-6112db769dea7cfee0deb4644b11978ae11f79c1bde13febfc9d619cbe7fcbc9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = 'delivery interrupted; queued for retry',\n claude_session_id = NULL,\n pasted_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE state = 'pasting' AND updated_at <= $1", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "6112db769dea7cfee0deb4644b11978ae11f79c1bde13febfc9d619cbe7fcbc9" +} diff --git a/crates/db/.sqlx/query-6c3bad4405429bf3b9ec81b7c6c1c73e1d9eebf058e7447bea9d43bb9bb91caf.json b/crates/db/.sqlx/query-6c3bad4405429bf3b9ec81b7c6c1c73e1d9eebf058e7447bea9d43bb9bb91caf.json new file mode 100644 index 00000000000..8ef74c5b147 --- /dev/null +++ b/crates/db/.sqlx/query-6c3bad4405429bf3b9ec81b7c6c1c73e1d9eebf058e7447bea9d43bb9bb91caf.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'pasted',\n updated_at = datetime('now', 'subsec')\n WHERE id = $1 AND state = 'pasting'", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "6c3bad4405429bf3b9ec81b7c6c1c73e1d9eebf058e7447bea9d43bb9bb91caf" +} diff --git a/crates/db/.sqlx/query-6dacb2e53db643ffd3c0b33f0e60c01323ad474e59e469db9638bb1d427685f3.json b/crates/db/.sqlx/query-6dacb2e53db643ffd3c0b33f0e60c01323ad474e59e469db9638bb1d427685f3.json new file mode 100644 index 00000000000..2c455c5b519 --- /dev/null +++ b/crates/db/.sqlx/query-6dacb2e53db643ffd3c0b33f0e60c01323ad474e59e469db9638bb1d427685f3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE cli_pane_bindings SET released_at = datetime('now', 'subsec')\n WHERE id = $1 AND released_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "6dacb2e53db643ffd3c0b33f0e60c01323ad474e59e469db9638bb1d427685f3" +} diff --git a/crates/db/.sqlx/query-6f7cfc58051988c9d05003dba3fc3579ceca4fc4137d88b5626977420aaef56e.json b/crates/db/.sqlx/query-6f7cfc58051988c9d05003dba3fc3579ceca4fc4137d88b5626977420aaef56e.json new file mode 100644 index 00000000000..06f9f0bac90 --- /dev/null +++ b/crates/db/.sqlx/query-6f7cfc58051988c9d05003dba3fc3579ceca4fc4137d88b5626977420aaef56e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'pasting',\n claude_session_id = $1,\n pasted_at = datetime('now', 'subsec'),\n failure_reason = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $2 AND state = 'queued'", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "6f7cfc58051988c9d05003dba3fc3579ceca4fc4137d88b5626977420aaef56e" +} diff --git a/crates/db/.sqlx/query-7dc08c213e156cb7d1bd2396d120101a752aa2b6efe8fe33f080ef33b9a016de.json b/crates/db/.sqlx/query-7dc08c213e156cb7d1bd2396d120101a752aa2b6efe8fe33f080ef33b9a016de.json new file mode 100644 index 00000000000..6fb7badb1e2 --- /dev/null +++ b/crates/db/.sqlx/query-7dc08c213e156cb7d1bd2396d120101a752aa2b6efe8fe33f080ef33b9a016de.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'cancelled',\n updated_at = datetime('now', 'subsec')\n WHERE id = $1 AND state = 'queued'", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "7dc08c213e156cb7d1bd2396d120101a752aa2b6efe8fe33f080ef33b9a016de" +} diff --git a/crates/db/.sqlx/query-8c1aa81a8ed2e05fa2074fdd427cfca531992f452301016f0f1618d67f17c09c.json b/crates/db/.sqlx/query-8c1aa81a8ed2e05fa2074fdd427cfca531992f452301016f0f1618d67f17c09c.json new file mode 100644 index 00000000000..983d0ad8768 --- /dev/null +++ b/crates/db/.sqlx/query-8c1aa81a8ed2e05fa2074fdd427cfca531992f452301016f0f1618d67f17c09c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE cli_pane_bindings SET claude_session_id = $1\n WHERE id = $2 AND released_at IS NULL\n AND bound_via = 'cli-fresh'\n AND claude_session_id IS NULL", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "8c1aa81a8ed2e05fa2074fdd427cfca531992f452301016f0f1618d67f17c09c" +} diff --git a/crates/db/.sqlx/query-953a08426ac82ecb9b581115fb7b9d85febfee88931db06d1757836165e9e35c.json b/crates/db/.sqlx/query-953a08426ac82ecb9b581115fb7b9d85febfee88931db06d1757836165e9e35c.json new file mode 100644 index 00000000000..317a3284098 --- /dev/null +++ b/crates/db/.sqlx/query-953a08426ac82ecb9b581115fb7b9d85febfee88931db06d1757836165e9e35c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = 'CLI submission was not acknowledged; queued for retry',\n claude_session_id = NULL,\n pasted_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE state = 'pasted' AND pasted_at <= $1", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "953a08426ac82ecb9b581115fb7b9d85febfee88931db06d1757836165e9e35c" +} diff --git a/crates/db/.sqlx/query-9ade237b8273f60e0acb6c1de93e4c748ed245a65c01c1c34e7076d05fda6c68.json b/crates/db/.sqlx/query-9ade237b8273f60e0acb6c1de93e4c748ed245a65c01c1c34e7076d05fda6c68.json new file mode 100644 index 00000000000..81b4185258a --- /dev/null +++ b/crates/db/.sqlx/query-9ade237b8273f60e0acb6c1de93e4c748ed245a65c01c1c34e7076d05fda6c68.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE cli_pane_bindings SET released_at = datetime('now', 'subsec')\n WHERE workspace_id = $1 AND released_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "9ade237b8273f60e0acb6c1de93e4c748ed245a65c01c1c34e7076d05fda6c68" +} diff --git a/crates/db/.sqlx/query-a1c5617fb090de7823da7297966510f19c64508c80cf42344ee563cf6d5a6c18.json b/crates/db/.sqlx/query-a1c5617fb090de7823da7297966510f19c64508c80cf42344ee563cf6d5a6c18.json new file mode 100644 index 00000000000..c0bbd909d6f --- /dev/null +++ b/crates/db/.sqlx/query-a1c5617fb090de7823da7297966510f19c64508c80cf42344ee563cf6d5a6c18.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "SELECT workspace_id AS \"workspace_id!: Uuid\",\n holder AS \"holder!: SpawnReservationHolder\",\n fence,\n created_at AS \"created_at!: DateTime\",\n expires_at AS \"expires_at!: DateTime\"\n FROM workspace_spawn_reservations\n WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "name": "workspace_id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "holder!: SpawnReservationHolder", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "fence", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "expires_at!: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false + ] + }, + "hash": "a1c5617fb090de7823da7297966510f19c64508c80cf42344ee563cf6d5a6c18" +} diff --git a/crates/db/.sqlx/query-7bee6850aa473a3b541fd008c37c7ba0cbcbe16452817a7797361f2e21f180d7.json b/crates/db/.sqlx/query-a36b90635fba777c0863cc9afec44c8141cfd2800a7ca34e8ea1c777a29d42ac.json similarity index 67% rename from crates/db/.sqlx/query-7bee6850aa473a3b541fd008c37c7ba0cbcbe16452817a7797361f2e21f180d7.json rename to crates/db/.sqlx/query-a36b90635fba777c0863cc9afec44c8141cfd2800a7ca34e8ea1c777a29d42ac.json index 1926d2eaa6c..5230e040284 100644 --- a/crates/db/.sqlx/query-7bee6850aa473a3b541fd008c37c7ba0cbcbe16452817a7797361f2e21f180d7.json +++ b/crates/db/.sqlx/query-a36b90635fba777c0863cc9afec44c8141cfd2800a7ca34e8ea1c777a29d42ac.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT r.file_id AS \"file_id!: Uuid\",\n r.line_seq,\n r.claude_session_id,\n r.uuid,\n r.parent_uuid,\n r.kind,\n r.ts,\n r.raw,\n r.disposition,\n (\n SELECT enl.execution_process_id\n FROM execution_native_links enl\n JOIN execution_processes linked_ep\n ON linked_ep.id = enl.execution_process_id\n WHERE enl.native_uuid = r.uuid\n ORDER BY linked_ep.created_at ASC\n LIMIT 1\n ) AS \"linked_execution_process_id: Uuid\",\n cat.execution_process_id AS \"bound_turn_execution_process_id: Uuid\",\n outbox.seq,\n f.dir_path,\n f.file_name,\n f.generation\n FROM cli_native_records r\n JOIN cli_native_files f ON f.id = r.file_id\n JOIN claude_session_links l\n ON l.claude_session_id = r.claude_session_id\n JOIN cli_ingest_outbox outbox\n ON outbox.file_id = r.file_id\n AND outbox.line_seq = r.line_seq\n AND outbox.session_id = l.session_id\n LEFT JOIN coding_agent_turns cat\n ON cat.id = r.bound_coding_agent_turn_id\n WHERE l.session_id = $1\n AND f.generation = (\n SELECT MAX(newer.generation)\n FROM cli_native_files newer\n WHERE newer.dir_path = f.dir_path\n AND newer.file_name = f.file_name\n )\n ORDER BY outbox.seq ASC", + "query": "SELECT r.file_id AS \"file_id!: Uuid\",\n r.line_seq,\n r.claude_session_id,\n r.uuid,\n r.parent_uuid,\n r.kind,\n r.ts,\n r.raw,\n r.disposition,\n (\n SELECT enl.execution_process_id\n FROM execution_native_links enl\n JOIN execution_processes linked_ep\n ON linked_ep.id = enl.execution_process_id\n WHERE enl.native_uuid = r.uuid\n ORDER BY linked_ep.created_at ASC\n LIMIT 1\n ) AS \"linked_execution_process_id: Uuid\",\n cat.execution_process_id AS \"bound_turn_execution_process_id: Uuid\",\n r.bound_queued_message_id AS \"bound_queued_message_id: Uuid\",\n outbox.seq,\n f.dir_path,\n f.file_name,\n f.generation\n FROM cli_native_records r\n JOIN cli_native_files f ON f.id = r.file_id\n JOIN claude_session_links l\n ON l.claude_session_id = r.claude_session_id\n JOIN cli_ingest_outbox outbox\n ON outbox.file_id = r.file_id\n AND outbox.line_seq = r.line_seq\n AND outbox.session_id = l.session_id\n LEFT JOIN coding_agent_turns cat\n ON cat.id = r.bound_coding_agent_turn_id\n WHERE l.session_id = $1\n AND f.generation = (\n SELECT MAX(newer.generation)\n FROM cli_native_files newer\n WHERE newer.dir_path = f.dir_path\n AND newer.file_name = f.file_name\n )\n ORDER BY outbox.seq ASC", "describe": { "columns": [ { @@ -59,23 +59,28 @@ "type_info": "Blob" }, { - "name": "seq", + "name": "bound_queued_message_id: Uuid", "ordinal": 11, + "type_info": "Blob" + }, + { + "name": "seq", + "ordinal": 12, "type_info": "Integer" }, { "name": "dir_path", - "ordinal": 12, + "ordinal": 13, "type_info": "Text" }, { "name": "file_name", - "ordinal": 13, + "ordinal": 14, "type_info": "Text" }, { "name": "generation", - "ordinal": 14, + "ordinal": 15, "type_info": "Integer" } ], @@ -94,11 +99,12 @@ false, true, true, + true, false, false, false, false ] }, - "hash": "7bee6850aa473a3b541fd008c37c7ba0cbcbe16452817a7797361f2e21f180d7" + "hash": "a36b90635fba777c0863cc9afec44c8141cfd2800a7ca34e8ea1c777a29d42ac" } diff --git a/crates/db/.sqlx/query-cde081fce13f5de497bb5f4b22e6093cf67bc7ad9912047ab2c23c7bc95d251f.json b/crates/db/.sqlx/query-cde081fce13f5de497bb5f4b22e6093cf67bc7ad9912047ab2c23c7bc95d251f.json new file mode 100644 index 00000000000..80624c225ad --- /dev/null +++ b/crates/db/.sqlx/query-cde081fce13f5de497bb5f4b22e6093cf67bc7ad9912047ab2c23c7bc95d251f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT OR IGNORE INTO workspace_spawn_reservations\n (workspace_id, holder, fence, created_at, expires_at)\n VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "cde081fce13f5de497bb5f4b22e6093cf67bc7ad9912047ab2c23c7bc95d251f" +} diff --git a/crates/db/.sqlx/query-ce4e0dac1ea70528f91d71049432c7f0bce031e5ba46a103967853295256b7a3.json b/crates/db/.sqlx/query-ce4e0dac1ea70528f91d71049432c7f0bce031e5ba46a103967853295256b7a3.json new file mode 100644 index 00000000000..2d72a6c3a45 --- /dev/null +++ b/crates/db/.sqlx/query-ce4e0dac1ea70528f91d71049432c7f0bce031e5ba46a103967853295256b7a3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "DELETE FROM workspace_spawn_reservations\n WHERE workspace_id = $1 AND expires_at <= $2", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "ce4e0dac1ea70528f91d71049432c7f0bce031e5ba46a103967853295256b7a3" +} diff --git a/crates/db/Cargo.toml b/crates/db/Cargo.toml index 42804e47ed2..e5d62c7dd05 100644 --- a/crates/db/Cargo.toml +++ b/crates/db/Cargo.toml @@ -19,3 +19,6 @@ serde_with = { workspace = true } strum = "0.27.2" strum_macros = "0.27.2" futures = "0.3.32" + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/db/migrations/20260721090000_add_session_queued_messages.sql b/crates/db/migrations/20260721090000_add_session_queued_messages.sql new file mode 100644 index 00000000000..4d65bb2ea81 --- /dev/null +++ b/crates/db/migrations/20260721090000_add_session_queued_messages.sql @@ -0,0 +1,29 @@ +-- Durable, single-slot collaboration queue. Terminal delivery remains active +-- through `pasted`; terminal states no longer occupy the session's slot. +CREATE TABLE session_queued_messages ( + id BLOB PRIMARY KEY, + session_id BLOB NOT NULL, + prompt TEXT NOT NULL, + executor_config TEXT, + source TEXT NOT NULL + CHECK (source IN ('ui', 'recovery')), + state TEXT NOT NULL + CHECK (state IN ( + 'queued', 'pasting', 'pasted', 'imported', + 'failed', 'consumed', 'cancelled' + )), + failure_reason TEXT, + claude_session_id TEXT, + pasted_at TEXT, + acked_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX idx_session_queued_messages_active_session + ON session_queued_messages (session_id) + WHERE state IN ('queued', 'pasting', 'pasted'); + +CREATE INDEX idx_session_queued_messages_active_scan + ON session_queued_messages (state, updated_at); diff --git a/crates/db/migrations/20260721091000_add_cli_delivery_ack_and_foreign_writer.sql b/crates/db/migrations/20260721091000_add_cli_delivery_ack_and_foreign_writer.sql new file mode 100644 index 00000000000..3ce20ac4da8 --- /dev/null +++ b/crates/db/migrations/20260721091000_add_cli_delivery_ack_and_foreign_writer.sql @@ -0,0 +1,10 @@ +ALTER TABLE cli_native_records +ADD COLUMN bound_queued_message_id BLOB + REFERENCES session_queued_messages(id) ON DELETE SET NULL; + +CREATE UNIQUE INDEX idx_cli_native_records_bound_queued_message + ON cli_native_records (bound_queued_message_id) + WHERE bound_queued_message_id IS NOT NULL; + +ALTER TABLE claude_session_links +ADD COLUMN foreign_writer_seen_at TEXT; diff --git a/crates/db/migrations/20260721092000_add_cli_pane_bindings.sql b/crates/db/migrations/20260721092000_add_cli_pane_bindings.sql new file mode 100644 index 00000000000..d51d60b262d --- /dev/null +++ b/crates/db/migrations/20260721092000_add_cli_pane_bindings.sql @@ -0,0 +1,20 @@ +CREATE TABLE cli_pane_bindings ( + id BLOB PRIMARY KEY, + workspace_id BLOB NOT NULL, + session_id BLOB NOT NULL, + claude_session_id TEXT, + bound_via TEXT NOT NULL + CHECK (bound_via IN ('cli-resume', 'cli-fresh')), + created_at TEXT NOT NULL DEFAULT (datetime('now', 'subsec')), + released_at TEXT, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE +); + +CREATE UNIQUE INDEX idx_cli_pane_bindings_active_workspace + ON cli_pane_bindings (workspace_id) + WHERE released_at IS NULL; + +CREATE INDEX idx_cli_pane_bindings_active_session + ON cli_pane_bindings (session_id) + WHERE released_at IS NULL; diff --git a/crates/db/migrations/20260721093000_add_workspace_spawn_reservations.sql b/crates/db/migrations/20260721093000_add_workspace_spawn_reservations.sql new file mode 100644 index 00000000000..8a031196ced --- /dev/null +++ b/crates/db/migrations/20260721093000_add_workspace_spawn_reservations.sql @@ -0,0 +1,11 @@ +CREATE TABLE workspace_spawn_reservations ( + workspace_id BLOB PRIMARY KEY, + holder TEXT NOT NULL CHECK (holder IN ('executor', 'cli')), + fence TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE +); + +CREATE INDEX idx_workspace_spawn_reservations_expiry + ON workspace_spawn_reservations (expires_at); diff --git a/crates/db/src/models/claude_session_link.rs b/crates/db/src/models/claude_session_link.rs index 2dfa7c8e2a4..f5de7c3740a 100644 --- a/crates/db/src/models/claude_session_link.rs +++ b/crates/db/src/models/claude_session_link.rs @@ -24,6 +24,7 @@ pub struct ClaudeSessionLink { pub cwd: String, pub bound_via: ClaudeSessionBoundVia, pub created_at: DateTime, + pub foreign_writer_seen_at: Option>, } #[derive(Debug, Clone)] @@ -57,7 +58,8 @@ impl ClaudeSessionLink { workspace_id AS "workspace_id!: Uuid", cwd AS "cwd!", bound_via AS "bound_via!: ClaudeSessionBoundVia", - created_at AS "created_at!: DateTime" + created_at AS "created_at!: DateTime", + foreign_writer_seen_at AS "foreign_writer_seen_at: DateTime" FROM claude_session_links WHERE claude_session_id = $1"#, claude_session_id diff --git a/crates/db/src/models/cli_native_record.rs b/crates/db/src/models/cli_native_record.rs index 3783da814ac..5d4b2200484 100644 --- a/crates/db/src/models/cli_native_record.rs +++ b/crates/db/src/models/cli_native_record.rs @@ -20,6 +20,7 @@ pub struct CliNativeRecord { pub raw: String, pub disposition: String, pub bound_coding_agent_turn_id: Option, + pub bound_queued_message_id: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -92,6 +93,7 @@ pub struct SessionNativeRecord { pub disposition: String, pub linked_execution_process_id: Option, pub bound_turn_execution_process_id: Option, + pub bound_queued_message_id: Option, pub seq: i64, pub dir_path: String, pub file_name: String, @@ -400,6 +402,7 @@ impl CliNativeRecord { LIMIT 1 ) AS "linked_execution_process_id: Uuid", cat.execution_process_id AS "bound_turn_execution_process_id: Uuid", + r.bound_queued_message_id AS "bound_queued_message_id: Uuid", outbox.seq, f.dir_path, f.file_name, diff --git a/crates/db/src/models/cli_pane_binding.rs b/crates/db/src/models/cli_pane_binding.rs new file mode 100644 index 00000000000..f1c2ec16fc7 --- /dev/null +++ b/crates/db/src/models/cli_pane_binding.rs @@ -0,0 +1,149 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool, Type}; +use ts_rs::TS; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)] +#[sqlx(type_name = "TEXT", rename_all = "kebab-case")] +#[serde(rename_all = "kebab-case")] +pub enum CliPaneBoundVia { + CliResume, + CliFresh, +} + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] +pub struct CliPaneBinding { + pub id: Uuid, + pub workspace_id: Uuid, + pub session_id: Uuid, + pub claude_session_id: Option, + pub bound_via: CliPaneBoundVia, + pub created_at: DateTime, + pub released_at: Option>, +} + +impl CliPaneBinding { + const SELECT_FIELDS: &'static str = r#" + id, workspace_id, session_id, claude_session_id, bound_via, + created_at, released_at + "#; + + pub async fn record_launch( + pool: &SqlitePool, + workspace_id: Uuid, + session_id: Uuid, + claude_session_id: Option<&str>, + bound_via: CliPaneBoundVia, + ) -> Result { + let mut tx = pool.begin().await?; + sqlx::query!( + r#"UPDATE cli_pane_bindings SET released_at = datetime('now', 'subsec') + WHERE workspace_id = $1 AND released_at IS NULL"#, + workspace_id + ) + .execute(&mut *tx) + .await?; + let id = Uuid::new_v4(); + sqlx::query!( + r#"INSERT INTO cli_pane_bindings + (id, workspace_id, session_id, claude_session_id, bound_via) + VALUES ($1, $2, $3, $4, $5)"#, + id, + workspace_id, + session_id, + claude_session_id, + bound_via + ) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Self::find_by_id(pool, id) + .await? + .ok_or(sqlx::Error::RowNotFound) + } + + pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { + let sql = format!( + "SELECT {} FROM cli_pane_bindings WHERE id = ?", + Self::SELECT_FIELDS + ); + sqlx::query_as::<_, Self>(&sql) + .bind(id) + .fetch_optional(pool) + .await + } + + pub async fn find_active_for_workspace( + pool: &SqlitePool, + workspace_id: Uuid, + ) -> Result, sqlx::Error> { + let sql = format!( + "SELECT {} FROM cli_pane_bindings \ + WHERE workspace_id = ? AND released_at IS NULL LIMIT 1", + Self::SELECT_FIELDS + ); + sqlx::query_as::<_, Self>(&sql) + .bind(workspace_id) + .fetch_optional(pool) + .await + } + + pub async fn find_active_for_session( + pool: &SqlitePool, + session_id: Uuid, + ) -> Result, sqlx::Error> { + let sql = format!( + "SELECT {} FROM cli_pane_bindings \ + WHERE session_id = ? AND released_at IS NULL LIMIT 1", + Self::SELECT_FIELDS + ); + sqlx::query_as::<_, Self>(&sql) + .bind(session_id) + .fetch_optional(pool) + .await + } + + pub async fn bind_discovered_sid( + pool: &SqlitePool, + id: Uuid, + claude_session_id: &str, + ) -> Result { + let result = sqlx::query!( + r#"UPDATE cli_pane_bindings SET claude_session_id = $1 + WHERE id = $2 AND released_at IS NULL + AND bound_via = 'cli-fresh' + AND claude_session_id IS NULL"#, + claude_session_id, + id + ) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn release(pool: &SqlitePool, id: Uuid) -> Result { + let result = sqlx::query!( + r#"UPDATE cli_pane_bindings SET released_at = datetime('now', 'subsec') + WHERE id = $1 AND released_at IS NULL"#, + id + ) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn release_workspace( + pool: &SqlitePool, + workspace_id: Uuid, + ) -> Result { + Ok(sqlx::query!( + r#"UPDATE cli_pane_bindings SET released_at = datetime('now', 'subsec') + WHERE workspace_id = $1 AND released_at IS NULL"#, + workspace_id + ) + .execute(pool) + .await? + .rows_affected()) + } +} diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs index b7f3eb3fb24..1b95adc165e 100644 --- a/crates/db/src/models/mod.rs +++ b/crates/db/src/models/mod.rs @@ -2,6 +2,7 @@ pub mod claude_session_link; pub mod cli_ingest_outbox; pub mod cli_native_file; pub mod cli_native_record; +pub mod cli_pane_binding; pub mod coding_agent_turn; pub mod execution_native_link; pub mod execution_process; @@ -16,8 +17,10 @@ pub mod repo; pub mod requests; pub mod scratch; pub mod session; +pub mod session_queued_message; pub mod tag; pub mod task; pub mod workspace; pub mod workspace_cli_activity; pub mod workspace_repo; +pub mod workspace_spawn_reservation; diff --git a/crates/db/src/models/session_queued_message.rs b/crates/db/src/models/session_queued_message.rs new file mode 100644 index 00000000000..97fc5dadd47 --- /dev/null +++ b/crates/db/src/models/session_queued_message.rs @@ -0,0 +1,545 @@ +use chrono::{DateTime, Duration, Utc}; +use executors::profile::ExecutorConfig; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool, Type}; +use ts_rs::TS; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)] +#[sqlx(type_name = "TEXT", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum QueuedMessageSource { + Ui, + Recovery, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)] +#[sqlx(type_name = "TEXT", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum QueuedMessageState { + Queued, + Pasting, + Pasted, + Imported, + Failed, + Consumed, + Cancelled, +} + +impl QueuedMessageState { + pub fn is_active(self) -> bool { + matches!(self, Self::Queued | Self::Pasting | Self::Pasted) + } +} + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] +pub struct SessionQueuedMessage { + pub id: Uuid, + pub session_id: Uuid, + pub prompt: String, + /// Serialized [`ExecutorConfig`]. Paste delivery does not need it, but a + /// queued prompt must retain it across a restart before executor dispatch. + #[ts(type = "ExecutorConfig | null")] + pub executor_config: Option, + pub source: QueuedMessageSource, + pub state: QueuedMessageState, + pub failure_reason: Option, + pub claude_session_id: Option, + pub pasted_at: Option>, + pub acked_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone)] +pub enum StoreQueuedMessageResult { + Stored(SessionQueuedMessage), + Conflict(SessionQueuedMessage), +} + +#[derive(Debug, Clone)] +pub enum CancelQueuedMessageResult { + Empty, + Cancelled(SessionQueuedMessage), + Conflict(SessionQueuedMessage), +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct QueueReconciliation { + pub imported: u64, + pub requeued_pasting: u64, + pub requeued_pasted: u64, +} + +impl SessionQueuedMessage { + const SELECT_FIELDS: &'static str = r#" + id, session_id, prompt, executor_config, source, state, + failure_reason, claude_session_id, pasted_at, acked_at, + created_at, updated_at + "#; + + pub fn parsed_executor_config(&self) -> Result, serde_json::Error> { + self.executor_config + .as_deref() + .map(serde_json::from_str) + .transpose() + } + + pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { + let sql = format!( + "SELECT {} FROM session_queued_messages WHERE id = ?", + Self::SELECT_FIELDS + ); + sqlx::query_as::<_, Self>(&sql) + .bind(id) + .fetch_optional(pool) + .await + } + + pub async fn find_active( + pool: &SqlitePool, + session_id: Uuid, + ) -> Result, sqlx::Error> { + let sql = format!( + "SELECT {} FROM session_queued_messages \ + WHERE session_id = ? AND state IN ('queued', 'pasting', 'pasted') \ + ORDER BY created_at DESC LIMIT 1", + Self::SELECT_FIELDS + ); + sqlx::query_as::<_, Self>(&sql) + .bind(session_id) + .fetch_optional(pool) + .await + } + + pub async fn list_active(pool: &SqlitePool) -> Result, sqlx::Error> { + let sql = format!( + "SELECT {} FROM session_queued_messages \ + WHERE state IN ('queued', 'pasting', 'pasted') \ + ORDER BY created_at ASC", + Self::SELECT_FIELDS + ); + sqlx::query_as::<_, Self>(&sql).fetch_all(pool).await + } + + /// Store the session's active slot. Replacement is deliberately limited + /// to `queued`; paste-in-flight rows always conflict. + pub async fn store( + pool: &SqlitePool, + session_id: Uuid, + prompt: &str, + executor_config: Option<&str>, + source: QueuedMessageSource, + replace: bool, + ) -> Result { + let mut tx = pool.begin().await?; + let sql = format!( + "SELECT {} FROM session_queued_messages \ + WHERE session_id = ? AND state IN ('queued', 'pasting', 'pasted') \ + ORDER BY created_at DESC LIMIT 1", + Self::SELECT_FIELDS + ); + let existing = sqlx::query_as::<_, Self>(&sql) + .bind(session_id) + .fetch_optional(&mut *tx) + .await?; + + let id = if let Some(existing) = existing { + if !replace || existing.state != QueuedMessageState::Queued { + tx.rollback().await?; + return Ok(StoreQueuedMessageResult::Conflict(existing)); + } + sqlx::query!( + r#"UPDATE session_queued_messages SET + prompt = $1, + executor_config = $2, + source = $3, + failure_reason = NULL, + claude_session_id = NULL, + pasted_at = NULL, + acked_at = NULL, + updated_at = datetime('now', 'subsec') + WHERE id = $4 AND state = 'queued'"#, + prompt, + executor_config, + source, + existing.id + ) + .execute(&mut *tx) + .await?; + existing.id + } else { + let id = Uuid::new_v4(); + sqlx::query!( + r#"INSERT INTO session_queued_messages + (id, session_id, prompt, executor_config, source, state) + VALUES ($1, $2, $3, $4, $5, 'queued')"#, + id, + session_id, + prompt, + executor_config, + source + ) + .execute(&mut *tx) + .await?; + id + }; + tx.commit().await?; + Ok(StoreQueuedMessageResult::Stored( + Self::find_by_id(pool, id) + .await? + .expect("stored queue row exists"), + )) + } + + /// Claim a queued row for either paste or executor delivery. `pasting` is + /// the crash-recoverable in-flight state for both; a NULL sid distinguishes + /// an executor claim from a terminal paste claim. + pub async fn claim( + pool: &SqlitePool, + id: Uuid, + claude_session_id: Option<&str>, + ) -> Result, sqlx::Error> { + sqlx::query!( + r#"UPDATE session_queued_messages SET + state = 'pasting', + claude_session_id = $1, + pasted_at = datetime('now', 'subsec'), + failure_reason = NULL, + updated_at = datetime('now', 'subsec') + WHERE id = $2 AND state = 'queued'"#, + claude_session_id, + id + ) + .execute(pool) + .await?; + let row = Self::find_by_id(pool, id).await?; + Ok(row.filter(|row| row.state == QueuedMessageState::Pasting)) + } + + pub async fn mark_pasted(pool: &SqlitePool, id: Uuid) -> Result { + let result = sqlx::query!( + r#"UPDATE session_queued_messages SET + state = 'pasted', + updated_at = datetime('now', 'subsec') + WHERE id = $1 AND state = 'pasting'"#, + id + ) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn requeue( + pool: &SqlitePool, + id: Uuid, + failure_reason: Option<&str>, + ) -> Result { + let result = sqlx::query!( + r#"UPDATE session_queued_messages SET + state = 'queued', + failure_reason = $1, + claude_session_id = NULL, + pasted_at = NULL, + acked_at = NULL, + updated_at = datetime('now', 'subsec') + WHERE id = $2 AND state IN ('pasting', 'pasted')"#, + failure_reason, + id + ) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn mark_consumed(pool: &SqlitePool, id: Uuid) -> Result { + let result = sqlx::query!( + r#"UPDATE session_queued_messages SET + state = 'consumed', + updated_at = datetime('now', 'subsec') + WHERE id = $1 AND state = 'pasting'"#, + id + ) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn cancel_queued( + pool: &SqlitePool, + session_id: Uuid, + ) -> Result { + let Some(active) = Self::find_active(pool, session_id).await? else { + return Ok(CancelQueuedMessageResult::Empty); + }; + if active.state != QueuedMessageState::Queued { + return Ok(CancelQueuedMessageResult::Conflict(active)); + } + sqlx::query!( + r#"UPDATE session_queued_messages SET + state = 'cancelled', + updated_at = datetime('now', 'subsec') + WHERE id = $1 AND state = 'queued'"#, + active.id + ) + .execute(pool) + .await?; + Ok(CancelQueuedMessageResult::Cancelled( + Self::find_by_id(pool, active.id) + .await? + .expect("cancelled queue row exists"), + )) + } + + /// Reconcile delivery rows after startup or a periodic drain. Ack evidence + /// wins before age-based recovery, so a crash after the import transaction + /// never causes a blind re-paste. + pub async fn reconcile( + pool: &SqlitePool, + now: DateTime, + pasting_grace: Duration, + paste_ack_timeout: Duration, + ) -> Result { + let imported = sqlx::query!( + r#"UPDATE session_queued_messages SET + state = 'imported', + acked_at = COALESCE(acked_at, datetime('now', 'subsec')), + updated_at = datetime('now', 'subsec') + WHERE state IN ('pasting', 'pasted') + AND EXISTS ( + SELECT 1 FROM cli_native_records record + WHERE record.bound_queued_message_id = session_queued_messages.id + )"# + ) + .execute(pool) + .await? + .rows_affected(); + + let pasting_cutoff = now - pasting_grace; + let requeued_pasting = sqlx::query!( + r#"UPDATE session_queued_messages SET + state = 'queued', + failure_reason = 'delivery interrupted; queued for retry', + claude_session_id = NULL, + pasted_at = NULL, + updated_at = datetime('now', 'subsec') + WHERE state = 'pasting' AND updated_at <= $1"#, + pasting_cutoff + ) + .execute(pool) + .await? + .rows_affected(); + + let pasted_cutoff = now - paste_ack_timeout; + let requeued_pasted = sqlx::query!( + r#"UPDATE session_queued_messages SET + state = 'queued', + failure_reason = 'CLI submission was not acknowledged; queued for retry', + claude_session_id = NULL, + pasted_at = NULL, + updated_at = datetime('now', 'subsec') + WHERE state = 'pasted' AND pasted_at <= $1"#, + pasted_cutoff + ) + .execute(pool) + .await? + .rows_affected(); + + Ok(QueueReconciliation { + imported, + requeued_pasting, + requeued_pasted, + }) + } +} + +#[cfg(test)] +mod tests { + use sqlx::sqlite::SqlitePoolOptions; + + use super::*; + use crate::models::{ + session::{CreateSession, Session}, + workspace::{CreateWorkspace, Workspace}, + }; + + async fn session_fixture() -> (SqlitePool, Uuid) { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + crate::run_migrations_for_tests(&pool).await.unwrap(); + let workspace_id = Uuid::new_v4(); + Workspace::create( + &pool, + &CreateWorkspace { + branch: "main".to_string(), + name: Some("queue fixture".to_string()), + }, + workspace_id, + ) + .await + .unwrap(); + let session_id = Uuid::new_v4(); + Session::create( + &pool, + &CreateSession { + executor: Some("CLAUDE_CODE".to_string()), + name: None, + }, + session_id, + workspace_id, + ) + .await + .unwrap(); + (pool, session_id) + } + + #[tokio::test] + async fn active_slot_requires_explicit_queued_only_replacement() { + let (pool, session_id) = session_fixture().await; + let first = match SessionQueuedMessage::store( + &pool, + session_id, + "first", + None, + QueuedMessageSource::Ui, + false, + ) + .await + .unwrap() + { + StoreQueuedMessageResult::Stored(row) => row, + StoreQueuedMessageResult::Conflict(_) => panic!("empty slot must store"), + }; + + let conflict = SessionQueuedMessage::store( + &pool, + session_id, + "second", + None, + QueuedMessageSource::Recovery, + false, + ) + .await + .unwrap(); + assert!(matches!( + conflict, + StoreQueuedMessageResult::Conflict(ref row) if row.prompt == "first" + )); + + let replaced = SessionQueuedMessage::store( + &pool, + session_id, + "second", + None, + QueuedMessageSource::Recovery, + true, + ) + .await + .unwrap(); + assert!(matches!( + replaced, + StoreQueuedMessageResult::Stored(ref row) + if row.id == first.id + && row.prompt == "second" + && row.source == QueuedMessageSource::Recovery + )); + + SessionQueuedMessage::claim(&pool, first.id, Some("sid")) + .await + .unwrap() + .unwrap(); + let in_flight = SessionQueuedMessage::store( + &pool, + session_id, + "third", + None, + QueuedMessageSource::Ui, + true, + ) + .await + .unwrap(); + assert!(matches!( + in_flight, + StoreQueuedMessageResult::Conflict(ref row) + if row.state == QueuedMessageState::Pasting + )); + } + + #[tokio::test] + async fn delivery_reconciliation_requeues_interrupted_and_unacked_rows() { + let (pool, session_id) = session_fixture().await; + let row = match SessionQueuedMessage::store( + &pool, + session_id, + "recover me", + None, + QueuedMessageSource::Ui, + false, + ) + .await + .unwrap() + { + StoreQueuedMessageResult::Stored(row) => row, + StoreQueuedMessageResult::Conflict(_) => unreachable!(), + }; + SessionQueuedMessage::claim(&pool, row.id, Some("sid")) + .await + .unwrap(); + let old = Utc::now() - Duration::seconds(10); + sqlx::query("UPDATE session_queued_messages SET updated_at = ? WHERE id = ?") + .bind(old) + .bind(row.id) + .execute(&pool) + .await + .unwrap(); + let recovered = SessionQueuedMessage::reconcile( + &pool, + Utc::now(), + Duration::seconds(5), + Duration::seconds(30), + ) + .await + .unwrap(); + assert_eq!(recovered.requeued_pasting, 1); + assert_eq!( + SessionQueuedMessage::find_active(&pool, session_id) + .await + .unwrap() + .unwrap() + .state, + QueuedMessageState::Queued + ); + + SessionQueuedMessage::claim(&pool, row.id, Some("sid")) + .await + .unwrap(); + SessionQueuedMessage::mark_pasted(&pool, row.id) + .await + .unwrap(); + let old = Utc::now() - Duration::seconds(31); + sqlx::query("UPDATE session_queued_messages SET pasted_at = ? WHERE id = ?") + .bind(old) + .bind(row.id) + .execute(&pool) + .await + .unwrap(); + let recovered = SessionQueuedMessage::reconcile( + &pool, + Utc::now(), + Duration::seconds(5), + Duration::seconds(30), + ) + .await + .unwrap(); + assert_eq!(recovered.requeued_pasted, 1); + let active = SessionQueuedMessage::find_active(&pool, session_id) + .await + .unwrap() + .unwrap(); + assert_eq!(active.state, QueuedMessageState::Queued); + assert!(active.failure_reason.unwrap().contains("not acknowledged")); + } +} diff --git a/crates/db/src/models/workspace_spawn_reservation.rs b/crates/db/src/models/workspace_spawn_reservation.rs new file mode 100644 index 00000000000..779c7d3ca0d --- /dev/null +++ b/crates/db/src/models/workspace_spawn_reservation.rs @@ -0,0 +1,192 @@ +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, SqlitePool, Type}; +use ts_rs::TS; +use uuid::Uuid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)] +#[sqlx(type_name = "TEXT", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum SpawnReservationHolder { + Executor, + Cli, +} + +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] +pub struct WorkspaceSpawnReservation { + pub workspace_id: Uuid, + pub holder: SpawnReservationHolder, + pub fence: String, + pub created_at: DateTime, + pub expires_at: DateTime, +} + +impl WorkspaceSpawnReservation { + pub const DEFAULT_TTL: Duration = Duration::seconds(15); + + pub async fn acquire( + pool: &SqlitePool, + workspace_id: Uuid, + holder: SpawnReservationHolder, + ) -> Result, sqlx::Error> { + Self::acquire_at(pool, workspace_id, holder, Utc::now(), Self::DEFAULT_TTL).await + } + + pub async fn acquire_at( + pool: &SqlitePool, + workspace_id: Uuid, + holder: SpawnReservationHolder, + now: DateTime, + ttl: Duration, + ) -> Result, sqlx::Error> { + let mut tx = pool.begin().await?; + sqlx::query!( + r#"DELETE FROM workspace_spawn_reservations + WHERE workspace_id = $1 AND expires_at <= $2"#, + workspace_id, + now + ) + .execute(&mut *tx) + .await?; + + let fence = Uuid::new_v4().to_string(); + let expires_at = now + ttl; + let inserted = sqlx::query!( + r#"INSERT OR IGNORE INTO workspace_spawn_reservations + (workspace_id, holder, fence, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5)"#, + workspace_id, + holder, + fence, + now, + expires_at + ) + .execute(&mut *tx) + .await? + .rows_affected(); + tx.commit().await?; + + if inserted == 0 { + return Ok(None); + } + Ok(Some(Self { + workspace_id, + holder, + fence, + created_at: now, + expires_at, + })) + } + + pub async fn release( + pool: &SqlitePool, + workspace_id: Uuid, + fence: &str, + ) -> Result { + let result = sqlx::query!( + r#"DELETE FROM workspace_spawn_reservations + WHERE workspace_id = $1 AND fence = $2"#, + workspace_id, + fence + ) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) + } + + pub async fn find(pool: &SqlitePool, workspace_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as!( + Self, + r#"SELECT workspace_id AS "workspace_id!: Uuid", + holder AS "holder!: SpawnReservationHolder", + fence, + created_at AS "created_at!: DateTime", + expires_at AS "expires_at!: DateTime" + FROM workspace_spawn_reservations + WHERE workspace_id = $1"#, + workspace_id + ) + .fetch_optional(pool) + .await + } +} + +#[cfg(test)] +mod tests { + use sqlx::sqlite::SqlitePoolOptions; + + use super::*; + use crate::models::workspace::{CreateWorkspace, Workspace}; + + #[tokio::test] + async fn reservation_fence_and_ttl_prevent_overlapping_spawns() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + crate::run_migrations_for_tests(&pool).await.unwrap(); + let workspace_id = Uuid::new_v4(); + Workspace::create( + &pool, + &CreateWorkspace { + branch: "main".to_string(), + name: Some("reservation fixture".to_string()), + }, + workspace_id, + ) + .await + .unwrap(); + let now = Utc::now(); + let first = WorkspaceSpawnReservation::acquire_at( + &pool, + workspace_id, + SpawnReservationHolder::Executor, + now, + Duration::seconds(15), + ) + .await + .unwrap() + .unwrap(); + assert!( + WorkspaceSpawnReservation::acquire_at( + &pool, + workspace_id, + SpawnReservationHolder::Cli, + now + Duration::seconds(14), + Duration::seconds(15), + ) + .await + .unwrap() + .is_none() + ); + assert!( + !WorkspaceSpawnReservation::release(&pool, workspace_id, "wrong-fence") + .await + .unwrap() + ); + + let second = WorkspaceSpawnReservation::acquire_at( + &pool, + workspace_id, + SpawnReservationHolder::Cli, + now + Duration::seconds(16), + Duration::seconds(15), + ) + .await + .unwrap() + .expect("expired reservation must be replaced"); + assert_ne!(first.fence, second.fence); + assert_eq!(second.holder, SpawnReservationHolder::Cli); + assert!( + !WorkspaceSpawnReservation::release(&pool, workspace_id, &first.fence) + .await + .unwrap() + ); + assert!( + WorkspaceSpawnReservation::release(&pool, workspace_id, &second.fence) + .await + .unwrap() + ); + } +} From 5c5d60f7b70f5a1149fdac5881a8d662fa250473 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 07:14:49 +0000 Subject: [PATCH 02/36] feat(services): persist queued follow-up slots --- crates/local-deployment/src/container.rs | 12 +- crates/local-deployment/src/lib.rs | 2 +- crates/server/src/error.rs | 4 + crates/server/src/routes/scratch.rs | 4 +- crates/server/src/routes/sessions/queue.rs | 125 ++++++---- .../services/src/services/queued_message.rs | 228 ++++++++++++++---- 6 files changed, 276 insertions(+), 99 deletions(-) diff --git a/crates/local-deployment/src/container.rs b/crates/local-deployment/src/container.rs index c6086b029f2..2d2084eac0a 100644 --- a/crates/local-deployment/src/container.rs +++ b/crates/local-deployment/src/container.rs @@ -744,8 +744,10 @@ impl LocalContainerService { ExecutionProcessStatus::Failed | ExecutionProcessStatus::Killed ); - if let Some(queued_msg) = - container.queued_message_service.take_queued(ctx.session.id) + if let Ok(Some(queued_msg)) = container + .queued_message_service + .take_queued(ctx.session.id) + .await { if should_execute_queued { tracing::info!( @@ -827,8 +829,10 @@ impl LocalContainerService { .unwrap_or(true); if !has_running_agent - && let Some(queued_msg) = - container.queued_message_service.take_queued(ctx.session.id) + && let Ok(Some(queued_msg)) = container + .queued_message_service + .take_queued(ctx.session.id) + .await { tracing::info!( "Parallel setup script finished with queued message for session {}, starting follow-up", diff --git a/crates/local-deployment/src/lib.rs b/crates/local-deployment/src/lib.rs index fa0b3439fa4..7a5eae6e355 100644 --- a/crates/local-deployment/src/lib.rs +++ b/crates/local-deployment/src/lib.rs @@ -162,7 +162,7 @@ impl Deployment for LocalDeployment { } let approvals = Approvals::new(); - let queued_message_service = QueuedMessageService::new(); + let queued_message_service = QueuedMessageService::new(db.clone()); let oauth_credentials = Arc::new(OAuthCredentials::new(credentials_path())); if let Err(e) = oauth_credentials.load().await { diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index 7645a115d1a..202bbae581c 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -22,6 +22,7 @@ use services::services::{ config::{ConfigError, EditorOpenError}, container::ContainerError, file::FileError, + queued_message::QueuedMessageError, remote_client::RemoteClientError, repo::RepoError as RepoServiceError, }; @@ -62,6 +63,8 @@ pub enum ApiError { Config(#[from] ConfigError), #[error(transparent)] File(#[from] FileError), + #[error(transparent)] + QueuedMessage(#[from] QueuedMessageError), #[error("Multipart error: {0}")] Multipart(#[from] MultipartError), #[error("IO error: {0}")] @@ -460,6 +463,7 @@ impl IntoResponse for ApiError { error_type: "FileError", message: Some("Failed to process file. Please try again.".into()), }, + ApiError::QueuedMessage(_) => ErrorInfo::internal("QueuedMessageError"), ApiError::EditorOpen(EditorOpenError::LaunchFailed { .. }) => { ErrorInfo::internal("EditorLaunchError") diff --git a/crates/server/src/routes/scratch.rs b/crates/server/src/routes/scratch.rs index 8e9ceaa072a..c149a65db2f 100644 --- a/crates/server/src/routes/scratch.rs +++ b/crates/server/src/routes/scratch.rs @@ -48,7 +48,7 @@ pub async fn create_scratch( ) -> Result>, ApiError> { // Reject edits to draft_follow_up if a message is queued for this workspace if matches!(scratch_type, ScratchType::DraftFollowUp) - && deployment.queued_message_service().has_queued(id) + && deployment.queued_message_service().has_queued(id).await? { return Err(ApiError::BadRequest( "Cannot edit scratch while a message is queued".to_string(), @@ -72,7 +72,7 @@ pub async fn update_scratch( ) -> Result>, ApiError> { // Reject edits to draft_follow_up if a message is queued for this workspace if matches!(scratch_type, ScratchType::DraftFollowUp) - && deployment.queued_message_service().has_queued(id) + && deployment.queued_message_service().has_queued(id).await? { return Err(ApiError::BadRequest( "Cannot edit scratch while a message is queued".to_string(), diff --git a/crates/server/src/routes/sessions/queue.rs b/crates/server/src/routes/sessions/queue.rs index 96b418dafd6..b039750398c 100644 --- a/crates/server/src/routes/sessions/queue.rs +++ b/crates/server/src/routes/sessions/queue.rs @@ -1,83 +1,102 @@ use axum::{ - Extension, Json, Router, extract::State, middleware::from_fn_with_state, + Extension, Json, Router, extract::State, http::StatusCode, middleware::from_fn_with_state, response::Json as ResponseJson, routing::get, }; -use db::models::{scratch::DraftFollowUpData, session::Session}; +use db::models::{ + scratch::DraftFollowUpData, session::Session, session_queued_message::QueuedMessageSource, +}; use deployment::Deployment; use executors::profile::ExecutorConfig; use serde::Deserialize; -use services::services::queued_message::QueueStatus; +use services::services::queued_message::{QueueMutation, QueueStatus}; use ts_rs::TS; use utils::response::ApiResponse; use crate::{DeploymentImpl, error::ApiError, middleware::load_session_middleware}; -/// Request body for queueing a follow-up message #[derive(Debug, Deserialize, TS)] -struct QueueMessageRequest { +pub struct QueueMessageRequest { pub message: String, pub executor_config: ExecutorConfig, + #[serde(default)] + pub replace: bool, +} + +type QueueResponse = ( + StatusCode, + ResponseJson>, +); + +fn mutation_response(result: QueueMutation) -> QueueResponse { + match result { + QueueMutation::Stored(status) => { + (StatusCode::OK, ResponseJson(ApiResponse::success(status))) + } + QueueMutation::Conflict(status) => ( + StatusCode::CONFLICT, + ResponseJson(ApiResponse::error_with_data(status)), + ), + } } -/// Queue a follow-up message to be executed when the current execution finishes async fn queue_message( Extension(session): Extension, State(deployment): State, Json(payload): Json, -) -> Result>, ApiError> { +) -> Result { let data = DraftFollowUpData { message: payload.message, executor_config: payload.executor_config, }; - - let queued = deployment + let result = deployment .queued_message_service() - .queue_message(session.id, data); + .queue_message(session.id, data, QueuedMessageSource::Ui, payload.replace) + .await?; - deployment - .track_if_analytics_allowed( - "follow_up_queued", - serde_json::json!({ - "session_id": session.id.to_string(), - "workspace_id": session.workspace_id.to_string(), - }), - ) - .await; - - Ok(ResponseJson(ApiResponse::success(QueueStatus::Queued { - message: queued, - }))) + if matches!(result, QueueMutation::Stored(_)) { + deployment + .track_if_analytics_allowed( + "follow_up_queued", + serde_json::json!({ + "session_id": session.id.to_string(), + "workspace_id": session.workspace_id.to_string(), + }), + ) + .await; + } + Ok(mutation_response(result)) } -/// Cancel a queued follow-up message async fn cancel_queued_message( Extension(session): Extension, State(deployment): State, -) -> Result>, ApiError> { - deployment +) -> Result { + let result = deployment .queued_message_service() - .cancel_queued(session.id); - - deployment - .track_if_analytics_allowed( - "follow_up_queue_cancelled", - serde_json::json!({ - "session_id": session.id.to_string(), - "workspace_id": session.workspace_id.to_string(), - }), - ) - .await; - - Ok(ResponseJson(ApiResponse::success(QueueStatus::Empty))) + .cancel_queued(session.id) + .await?; + if matches!(result, QueueMutation::Stored(_)) { + deployment + .track_if_analytics_allowed( + "follow_up_queue_cancelled", + serde_json::json!({ + "session_id": session.id.to_string(), + "workspace_id": session.workspace_id.to_string(), + }), + ) + .await; + } + Ok(mutation_response(result)) } -/// Get the current queue status for a session's workspace async fn get_queue_status( Extension(session): Extension, State(deployment): State, ) -> Result>, ApiError> { - let status = deployment.queued_message_service().get_status(session.id); - + let status = deployment + .queued_message_service() + .get_status(session.id) + .await?; Ok(ResponseJson(ApiResponse::success(status))) } @@ -94,3 +113,25 @@ pub(super) fn router(deployment: &DeploymentImpl) -> Router { load_session_middleware, )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn replace_conflict_returns_409_with_slot_status_body() { + let (status, ResponseJson(body)) = + mutation_response(QueueMutation::Conflict(QueueStatus::Empty)); + + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!( + serde_json::to_value(body).unwrap(), + serde_json::json!({ + "success": false, + "data": null, + "error_data": { "status": "empty" }, + "message": null + }) + ); + } +} diff --git a/crates/services/src/services/queued_message.rs b/crates/services/src/services/queued_message.rs index 7909bdaab71..0f41b4609e9 100644 --- a/crates/services/src/services/queued_message.rs +++ b/crates/services/src/services/queued_message.rs @@ -1,90 +1,218 @@ -use std::sync::Arc; - use chrono::{DateTime, Utc}; -use dashmap::DashMap; -use db::models::scratch::DraftFollowUpData; +use db::{ + DBService, + models::{ + scratch::DraftFollowUpData, + session_queued_message::{ + CancelQueuedMessageResult, QueuedMessageSource, QueuedMessageState, + SessionQueuedMessage, StoreQueuedMessageResult, + }, + }, +}; use serde::{Deserialize, Serialize}; +use thiserror::Error; use ts_rs::TS; use uuid::Uuid; -/// Represents a queued follow-up message for a session +#[derive(Debug, Error)] +pub enum QueuedMessageError { + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error("queued message {0} has no executor configuration")] + MissingExecutorConfig(Uuid), + #[error("queued message {id} has invalid executor configuration: {source}")] + InvalidExecutorConfig { + id: Uuid, + #[source] + source: serde_json::Error, + }, +} + +/// Durable frontend-facing representation of the session's collaboration +/// slot. The `data` field preserves the existing queue API shape while source +/// and delivery state expose the explicit Phase 2 contract. #[derive(Debug, Clone, Serialize, Deserialize, TS)] pub struct QueuedMessage { - /// The session this message is queued for + pub id: Uuid, pub session_id: Uuid, - /// The follow-up data (message + variant) pub data: DraftFollowUpData, - /// Timestamp when the message was queued + pub source: QueuedMessageSource, + pub state: QueuedMessageState, + pub failure_reason: Option, + pub claude_session_id: Option, + pub pasted_at: Option>, + pub acked_at: Option>, pub queued_at: DateTime, + pub updated_at: DateTime, +} + +impl TryFrom for QueuedMessage { + type Error = QueuedMessageError; + + fn try_from(row: SessionQueuedMessage) -> Result { + let Some(raw_config) = row.executor_config.as_deref() else { + return Err(QueuedMessageError::MissingExecutorConfig(row.id)); + }; + let executor_config = serde_json::from_str(raw_config) + .map_err(|source| QueuedMessageError::InvalidExecutorConfig { id: row.id, source })?; + Ok(Self { + id: row.id, + session_id: row.session_id, + data: DraftFollowUpData { + message: row.prompt, + executor_config, + }, + source: row.source, + state: row.state, + failure_reason: row.failure_reason, + claude_session_id: row.claude_session_id, + pasted_at: row.pasted_at, + acked_at: row.acked_at, + queued_at: row.created_at, + updated_at: row.updated_at, + }) + } } -/// Status of the queue for a session (for frontend display) #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[serde(tag = "status", rename_all = "snake_case")] pub enum QueueStatus { - /// No message queued Empty, - /// Message is queued and waiting for execution to complete Queued { message: QueuedMessage }, + Pasting { message: QueuedMessage }, + Pasted { message: QueuedMessage }, } -/// In-memory service for managing queued follow-up messages. -/// One queued message per session. +impl QueueStatus { + pub fn from_message(message: QueuedMessage) -> Self { + match message.state { + QueuedMessageState::Queued => Self::Queued { message }, + QueuedMessageState::Pasting => Self::Pasting { message }, + QueuedMessageState::Pasted => Self::Pasted { message }, + QueuedMessageState::Imported + | QueuedMessageState::Failed + | QueuedMessageState::Consumed + | QueuedMessageState::Cancelled => Self::Empty, + } + } + + pub fn message(&self) -> Option<&QueuedMessage> { + match self { + Self::Empty => None, + Self::Queued { message } | Self::Pasting { message } | Self::Pasted { message } => { + Some(message) + } + } + } +} + +#[derive(Debug, Clone)] +pub enum QueueMutation { + Stored(QueueStatus), + Conflict(QueueStatus), +} + +/// DB-backed single-slot queue. There is deliberately no process-local copy: +/// restarts, paste acknowledgement, routes, and executor finalization all read +/// the same SQLite state machine. #[derive(Clone)] pub struct QueuedMessageService { - queue: Arc>, + db: DBService, } impl QueuedMessageService { - pub fn new() -> Self { - Self { - queue: Arc::new(DashMap::new()), - } + pub fn new(db: DBService) -> Self { + Self { db } } - /// Queue a message for a session. Replaces any existing queued message. - pub fn queue_message(&self, session_id: Uuid, data: DraftFollowUpData) -> QueuedMessage { - let queued = QueuedMessage { + pub async fn queue_message( + &self, + session_id: Uuid, + data: DraftFollowUpData, + source: QueuedMessageSource, + replace: bool, + ) -> Result { + let executor_config = serde_json::to_string(&data.executor_config).map_err(|source| { + QueuedMessageError::InvalidExecutorConfig { + id: Uuid::nil(), + source, + } + })?; + match SessionQueuedMessage::store( + &self.db.pool, session_id, - data, - queued_at: Utc::now(), - }; - self.queue.insert(session_id, queued.clone()); - queued + &data.message, + Some(&executor_config), + source, + replace, + ) + .await? + { + StoreQueuedMessageResult::Stored(row) => Ok(QueueMutation::Stored( + QueueStatus::from_message(row.try_into()?), + )), + StoreQueuedMessageResult::Conflict(row) => Ok(QueueMutation::Conflict( + QueueStatus::from_message(row.try_into()?), + )), + } } - /// Cancel/remove a queued message for a session - pub fn cancel_queued(&self, session_id: Uuid) -> Option { - self.queue.remove(&session_id).map(|(_, v)| v) + pub async fn cancel_queued( + &self, + session_id: Uuid, + ) -> Result { + match SessionQueuedMessage::cancel_queued(&self.db.pool, session_id).await? { + CancelQueuedMessageResult::Empty => Ok(QueueMutation::Stored(QueueStatus::Empty)), + CancelQueuedMessageResult::Cancelled(_) => { + Ok(QueueMutation::Stored(QueueStatus::Empty)) + } + CancelQueuedMessageResult::Conflict(row) => Ok(QueueMutation::Conflict( + QueueStatus::from_message(row.try_into()?), + )), + } } - /// Get the queued message for a session (if any) - pub fn get_queued(&self, session_id: Uuid) -> Option { - self.queue.get(&session_id).map(|r| r.clone()) + pub async fn get_queued( + &self, + session_id: Uuid, + ) -> Result, QueuedMessageError> { + SessionQueuedMessage::find_active(&self.db.pool, session_id) + .await? + .map(TryInto::try_into) + .transpose() } - /// Take (remove and return) the queued message for a session. - /// Used by finalization flow to consume the queued message. - pub fn take_queued(&self, session_id: Uuid) -> Option { - self.queue.remove(&session_id).map(|(_, v)| v) + /// Transitional consumer for callers not yet moved to `CliCollabService`. + /// It atomically claims and consumes the durable row before returning it; + /// Phase 2 finalization uses `on_executor_finished` instead. + pub async fn take_queued( + &self, + session_id: Uuid, + ) -> Result, QueuedMessageError> { + let Some(row) = SessionQueuedMessage::find_active(&self.db.pool, session_id).await? else { + return Ok(None); + }; + if row.state != QueuedMessageState::Queued + || SessionQueuedMessage::claim(&self.db.pool, row.id, None) + .await? + .is_none() + { + return Ok(None); + } + SessionQueuedMessage::mark_consumed(&self.db.pool, row.id).await?; + Ok(Some(row.try_into()?)) } - /// Check if a session has a queued message - pub fn has_queued(&self, session_id: Uuid) -> bool { - self.queue.contains_key(&session_id) + pub async fn has_queued(&self, session_id: Uuid) -> Result { + Ok(SessionQueuedMessage::find_active(&self.db.pool, session_id) + .await? + .is_some()) } - /// Get queue status for frontend display - pub fn get_status(&self, session_id: Uuid) -> QueueStatus { - match self.get_queued(session_id) { - Some(msg) => QueueStatus::Queued { message: msg }, + pub async fn get_status(&self, session_id: Uuid) -> Result { + Ok(match self.get_queued(session_id).await? { + Some(message) => QueueStatus::from_message(message), None => QueueStatus::Empty, - } - } -} - -impl Default for QueuedMessageService { - fn default() -> Self { - Self::new() + }) } } From 41551cf4eb06c97c16e1026993828e1dc2cb1d5c Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 07:15:04 +0000 Subject: [PATCH 03/36] feat(services): add CLI collaboration dispatch gate --- ...40f76f7ec6ccae934ed020f9e84188cee1ea1.json | 12 + ...4d1749da7b665614d2678e19f67d6f4e5a01f.json | 56 + crates/db/src/models/claude_session_link.rs | 23 + .../db/src/models/session_queued_message.rs | 18 + crates/services/src/services/cli_collab.rs | 1011 +++++++++++++++++ crates/services/src/services/mod.rs | 1 + 6 files changed, 1121 insertions(+) create mode 100644 crates/db/.sqlx/query-b8873a892382ae4c58481773a3940f76f7ec6ccae934ed020f9e84188cee1ea1.json create mode 100644 crates/db/.sqlx/query-d9eefd7888d06ec1ec905ee90164d1749da7b665614d2678e19f67d6f4e5a01f.json create mode 100644 crates/services/src/services/cli_collab.rs diff --git a/crates/db/.sqlx/query-b8873a892382ae4c58481773a3940f76f7ec6ccae934ed020f9e84188cee1ea1.json b/crates/db/.sqlx/query-b8873a892382ae4c58481773a3940f76f7ec6ccae934ed020f9e84188cee1ea1.json new file mode 100644 index 00000000000..e0700a816bf --- /dev/null +++ b/crates/db/.sqlx/query-b8873a892382ae4c58481773a3940f76f7ec6ccae934ed020f9e84188cee1ea1.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n failure_reason = $1,\n updated_at = datetime('now', 'subsec')\n WHERE id = $2 AND state = 'queued'", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "b8873a892382ae4c58481773a3940f76f7ec6ccae934ed020f9e84188cee1ea1" +} diff --git a/crates/db/.sqlx/query-d9eefd7888d06ec1ec905ee90164d1749da7b665614d2678e19f67d6f4e5a01f.json b/crates/db/.sqlx/query-d9eefd7888d06ec1ec905ee90164d1749da7b665614d2678e19f67d6f4e5a01f.json new file mode 100644 index 00000000000..3da21831e1b --- /dev/null +++ b/crates/db/.sqlx/query-d9eefd7888d06ec1ec905ee90164d1749da7b665614d2678e19f67d6f4e5a01f.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "SELECT claude_session_id AS \"claude_session_id!\",\n session_id AS \"session_id!: Uuid\",\n workspace_id AS \"workspace_id!: Uuid\",\n cwd AS \"cwd!\",\n bound_via AS \"bound_via!: ClaudeSessionBoundVia\",\n created_at AS \"created_at!: DateTime\",\n foreign_writer_seen_at AS \"foreign_writer_seen_at: DateTime\"\n FROM claude_session_links\n WHERE session_id = $1\n ORDER BY created_at DESC\n LIMIT 1", + "describe": { + "columns": [ + { + "name": "claude_session_id!", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "session_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "workspace_id!: Uuid", + "ordinal": 2, + "type_info": "Blob" + }, + { + "name": "cwd!", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "bound_via!: ClaudeSessionBoundVia", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "foreign_writer_seen_at: DateTime", + "ordinal": 6, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "d9eefd7888d06ec1ec905ee90164d1749da7b665614d2678e19f67d6f4e5a01f" +} diff --git a/crates/db/src/models/claude_session_link.rs b/crates/db/src/models/claude_session_link.rs index f5de7c3740a..254d4f24c51 100644 --- a/crates/db/src/models/claude_session_link.rs +++ b/crates/db/src/models/claude_session_link.rs @@ -68,6 +68,29 @@ impl ClaudeSessionLink { .await } + pub async fn find_latest_for_session( + pool: &SqlitePool, + session_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + ClaudeSessionLink, + r#"SELECT claude_session_id AS "claude_session_id!", + session_id AS "session_id!: Uuid", + workspace_id AS "workspace_id!: Uuid", + cwd AS "cwd!", + bound_via AS "bound_via!: ClaudeSessionBoundVia", + created_at AS "created_at!: DateTime", + foreign_writer_seen_at AS "foreign_writer_seen_at: DateTime" + FROM claude_session_links + WHERE session_id = $1 + ORDER BY created_at DESC + LIMIT 1"#, + session_id + ) + .fetch_optional(pool) + .await + } + /// Executor evidence has precedence over a stale/manual association. /// When no executor has reported this sid, an existing link is returned; /// otherwise the caller must quarantine the file. diff --git a/crates/db/src/models/session_queued_message.rs b/crates/db/src/models/session_queued_message.rs index 97fc5dadd47..26ff8245471 100644 --- a/crates/db/src/models/session_queued_message.rs +++ b/crates/db/src/models/session_queued_message.rs @@ -252,6 +252,24 @@ impl SessionQueuedMessage { Ok(result.rows_affected() > 0) } + pub async fn set_failure_reason( + pool: &SqlitePool, + id: Uuid, + failure_reason: &str, + ) -> Result { + let result = sqlx::query!( + r#"UPDATE session_queued_messages SET + failure_reason = $1, + updated_at = datetime('now', 'subsec') + WHERE id = $2 AND state = 'queued'"#, + failure_reason, + id + ) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) + } + pub async fn mark_consumed(pool: &SqlitePool, id: Uuid) -> Result { let result = sqlx::query!( r#"UPDATE session_queued_messages SET diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs new file mode 100644 index 00000000000..f8f8fe8007f --- /dev/null +++ b/crates/services/src/services/cli_collab.rs @@ -0,0 +1,1011 @@ +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::{Arc, Weak}, + time::Duration, +}; + +use anyhow::Result as AnyhowResult; +use async_trait::async_trait; +use chrono::{Duration as ChronoDuration, Utc}; +use db::{ + DBService, + models::{ + claude_session_link::ClaudeSessionLink, + cli_pane_binding::CliPaneBinding, + coding_agent_turn::CodingAgentTurn, + execution_process::{ExecutionProcess, ExecutionProcessRunReason}, + session::Session, + session_queued_message::{ + QueuedMessageSource, QueuedMessageState, SessionQueuedMessage, StoreQueuedMessageResult, + }, + workspace::Workspace, + workspace_spawn_reservation::{SpawnReservationHolder, WorkspaceSpawnReservation}, + }, +}; +use executors::profile::ExecutorConfig; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tokio::sync::{Mutex, Notify}; +use tokio_util::sync::CancellationToken; +use ts_rs::TS; +use uuid::Uuid; + +use super::{ + claude_transcript_ingest::ClaudeTranscriptIngest, + queued_message::{QueueStatus, QueuedMessage, QueuedMessageError}, +}; + +const DRAIN_INTERVAL: Duration = Duration::from_secs(1); +const PASTING_STARTUP_GRACE: ChronoDuration = ChronoDuration::seconds(5); +const PASTE_ACK_TIMEOUT: ChronoDuration = ChronoDuration::seconds(30); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SidEvidence { + ConfirmedResume(String), + NoResumeArg, + Ambiguous, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProbeReport { + pub pane_session_exists: bool, + pub agent_running: Option, + pub sid_evidence: SidEvidence, + /// True when any authoritative tmux/process/DB component of the probe was + /// unreadable. Lease derivation treats this as busy. + pub probe_failed: bool, + /// Used by cli-fresh discovery: `Some(true)` means exactly one live Claude + /// process has `effective_dir` as its cwd. + pub only_active_claude_in_cwd: Option, +} + +impl ProbeReport { + pub fn failed() -> Self { + Self { + pane_session_exists: false, + agent_running: None, + sid_evidence: SidEvidence::Unknown, + probe_failed: true, + only_active_claude_in_cwd: None, + } + } +} + +#[async_trait] +pub trait CliWriterProbe: Send + Sync { + async fn probe( + &self, + workspace_id: Uuid, + effective_dir: &Path, + expected_sid: Option<&str>, + ) -> ProbeReport; +} + +#[async_trait] +pub trait CliPasteTransport: Send + Sync { + async fn paste_and_submit(&self, workspace_id: Uuid, text: &str) -> bool; + async fn pane_alive(&self, workspace_id: Uuid) -> bool; + async fn agent_running(&self, workspace_id: Uuid) -> Option; + async fn signal_resume_ready(&self, workspace_id: Uuid, sid: &str); +} + +/// Deployment-owned executor bridge. The trait lives in services so +/// `CliCollabService` never imports local-deployment; the local implementation +/// reuses `ContainerService::start_execution` and its normal bookkeeping. +#[async_trait] +pub trait CliExecutorDispatcher: Send + Sync { + async fn dispatch( + &self, + session: &Session, + prompt: &str, + executor_config: &ExecutorConfig, + ) -> AnyhowResult; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WriterLease { + Executor, + Cli { claude_session_id: Option }, + CliAmbiguous, + Free, + Busy, +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum DispatchOutcome { + Started { execution_process: ExecutionProcess }, + Queued { status: QueueStatus }, + RoutedToCli { delivery: QueueStatus }, + Conflict { status: QueueStatus }, +} + +#[derive(Debug, Error)] +pub enum CliCollabError { + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + Queue(#[from] QueuedMessageError), + #[error(transparent)] + Serialize(#[from] serde_json::Error), + #[error("session {0} has no workspace")] + WorkspaceMissing(Uuid), +} + +pub struct CliCollabService { + db: DBService, + probe: Arc, + transport: Arc, + dispatcher: Arc, + ingest: Option>, + session_locks: Mutex>>>, + notify: Notify, + routing_disabled: bool, + shutdown: CancellationToken, +} + +impl CliCollabService { + pub fn spawn( + db: DBService, + probe: Arc, + transport: Arc, + dispatcher: Arc, + ingest: Option>, + shutdown: CancellationToken, + ) -> Arc { + let service = Arc::new(Self { + db, + probe, + transport, + dispatcher, + ingest, + session_locks: Mutex::new(HashMap::new()), + notify: Notify::new(), + routing_disabled: std::env::var_os("DISABLE_CLI_COLLAB_ROUTING").is_some(), + shutdown, + }); + tokio::spawn(service.clone().run_drain()); + if let Some(ingest) = &service.ingest { + let updates = ingest.subscribe(); + tokio::spawn(service.clone().run_ingest_wakeup(updates)); + } + service + } + + async fn session_lock(&self, session_id: Uuid) -> Arc> { + let mut locks = self.session_locks.lock().await; + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(&session_id).and_then(Weak::upgrade) { + return lock; + } + let lock = Arc::new(Mutex::new(())); + locks.insert(session_id, Arc::downgrade(&lock)); + lock + } + + pub fn wake_drain(&self) { + self.notify.notify_one(); + } + + pub async fn derive_lease(&self, session: &Session) -> WriterLease { + let lock = self.session_lock(session.id).await; + let _guard = lock.lock().await; + self.derive_lease_locked(session).await + } + + async fn derive_lease_locked(&self, session: &Session) -> WriterLease { + match ExecutionProcess::has_running_coding_agent_for_session(&self.db.pool, session.id) + .await + { + Ok(true) => return WriterLease::Executor, + Ok(false) => {} + Err(error) => { + tracing::warn!(?error, session_id = %session.id, "executor lease probe failed closed"); + return WriterLease::Busy; + } + } + + let binding = match CliPaneBinding::find_active_for_workspace( + &self.db.pool, + session.workspace_id, + ) + .await + { + Ok(binding) => binding, + Err(error) => { + tracing::warn!(?error, session_id = %session.id, "pane binding probe failed closed"); + return WriterLease::Busy; + } + }; + let workspace = match Workspace::find_by_id(&self.db.pool, session.workspace_id).await { + Ok(Some(workspace)) => workspace, + Ok(None) => return WriterLease::Busy, + Err(error) => { + tracing::warn!(?error, session_id = %session.id, "workspace lease probe failed closed"); + return WriterLease::Busy; + } + }; + let Some(effective_dir) = workspace + .container_ref + .as_deref() + .and_then(|root| session.effective_working_dir(Path::new(root))) + else { + return WriterLease::Busy; + }; + let expected_sid = match self.expected_sid(session.id).await { + Ok(sid) => sid, + Err(error) => { + tracing::warn!(?error, session_id = %session.id, "sid lease probe failed closed"); + return WriterLease::Busy; + } + }; + let report = self + .probe + .probe( + session.workspace_id, + &effective_dir, + expected_sid.as_deref(), + ) + .await; + if report.probe_failed { + return WriterLease::Busy; + } + if !report.pane_session_exists { + return WriterLease::Free; + } + match report.agent_running { + None => return WriterLease::Busy, + Some(false) => return WriterLease::Free, + Some(true) => {} + } + + let Some(binding) = binding.filter(|binding| binding.session_id == session.id) else { + return WriterLease::CliAmbiguous; + }; + match (expected_sid.as_deref(), &report.sid_evidence) { + (Some(expected), SidEvidence::ConfirmedResume(observed)) if expected == observed => { + WriterLease::Cli { + claude_session_id: Some(expected.to_string()), + } + } + (None, SidEvidence::NoResumeArg) if binding.claude_session_id.is_none() => { + WriterLease::Cli { + claude_session_id: None, + } + } + _ => WriterLease::CliAmbiguous, + } + } + + async fn expected_sid(&self, session_id: Uuid) -> Result, sqlx::Error> { + if let Some(info) = + CodingAgentTurn::find_latest_session_info(&self.db.pool, session_id).await? + { + return Ok(Some(info.session_id)); + } + Ok( + ClaudeSessionLink::find_latest_for_session(&self.db.pool, session_id) + .await? + .map(|link| link.claude_session_id), + ) + } + + pub async fn dispatch_gate( + &self, + session: &Session, + prompt: String, + executor_config: ExecutorConfig, + source: QueuedMessageSource, + replace: bool, + ) -> Result { + let lock = self.session_lock(session.id).await; + let _guard = lock.lock().await; + let existing = self + .prepare_existing_slot(session.id, &prompt, &executor_config, source, replace) + .await?; + if let PreparedSlot::Conflict(status) = existing { + return Ok(DispatchOutcome::Conflict { status }); + } + let existing = match existing { + PreparedSlot::None => None, + PreparedSlot::Stored(row) => Some(row), + PreparedSlot::Conflict(_) => unreachable!(), + }; + + let lease = self.derive_lease_locked(session).await; + let outcome = match lease { + WriterLease::Executor | WriterLease::CliAmbiguous | WriterLease::Busy => { + self.queue_or_status(session.id, &prompt, &executor_config, source, existing) + .await? + } + WriterLease::Cli { claude_session_id } if !self.routing_disabled => { + let row = match existing { + Some(row) => row, + None => { + self.store_slot(session.id, &prompt, &executor_config, source, false) + .await? + } + }; + self.paste_slot(session, row, claude_session_id.as_deref()) + .await? + } + WriterLease::Cli { .. } => { + self.queue_or_status(session.id, &prompt, &executor_config, source, existing) + .await? + } + WriterLease::Free => { + self.dispatch_executor_locked(session, &prompt, &executor_config, source, existing) + .await? + } + }; + if matches!(outcome, DispatchOutcome::Queued { .. }) { + self.notify.notify_one(); + } + Ok(outcome) + } + + pub async fn queue_only( + &self, + session: &Session, + prompt: String, + executor_config: ExecutorConfig, + source: QueuedMessageSource, + replace: bool, + ) -> Result { + let lock = self.session_lock(session.id).await; + let _guard = lock.lock().await; + let result = self + .store_slot_result(session.id, &prompt, &executor_config, source, replace) + .await?; + let outcome = match result { + StoreQueuedMessageResult::Stored(row) => DispatchOutcome::Queued { + status: Self::status_from_row(row)?, + }, + StoreQueuedMessageResult::Conflict(row) => DispatchOutcome::Conflict { + status: Self::status_from_row(row)?, + }, + }; + self.notify.notify_one(); + Ok(outcome) + } + + pub async fn cancel_queued(&self, session_id: Uuid) -> Result { + let lock = self.session_lock(session_id).await; + let _guard = lock.lock().await; + match db::models::session_queued_message::SessionQueuedMessage::cancel_queued( + &self.db.pool, + session_id, + ) + .await? + { + db::models::session_queued_message::CancelQueuedMessageResult::Empty + | db::models::session_queued_message::CancelQueuedMessageResult::Cancelled(_) => { + Ok(QueueStatus::Empty) + } + db::models::session_queued_message::CancelQueuedMessageResult::Conflict(row) => { + Self::status_from_row(row) + } + } + } + + pub async fn status(&self, session_id: Uuid) -> Result { + match SessionQueuedMessage::find_active(&self.db.pool, session_id).await? { + Some(row) => Self::status_from_row(row), + None => Ok(QueueStatus::Empty), + } + } + + async fn prepare_existing_slot( + &self, + session_id: Uuid, + prompt: &str, + executor_config: &ExecutorConfig, + source: QueuedMessageSource, + replace: bool, + ) -> Result { + let Some(active) = SessionQueuedMessage::find_active(&self.db.pool, session_id).await? + else { + return Ok(PreparedSlot::None); + }; + if !replace || active.state != QueuedMessageState::Queued { + return Ok(PreparedSlot::Conflict(Self::status_from_row(active)?)); + } + Ok(PreparedSlot::Stored( + self.store_slot(session_id, prompt, executor_config, source, true) + .await?, + )) + } + + async fn queue_or_status( + &self, + session_id: Uuid, + prompt: &str, + executor_config: &ExecutorConfig, + source: QueuedMessageSource, + existing: Option, + ) -> Result { + let row = match existing { + Some(row) => row, + None => { + self.store_slot(session_id, prompt, executor_config, source, false) + .await? + } + }; + Ok(DispatchOutcome::Queued { + status: Self::status_from_row(row)?, + }) + } + + async fn store_slot( + &self, + session_id: Uuid, + prompt: &str, + executor_config: &ExecutorConfig, + source: QueuedMessageSource, + replace: bool, + ) -> Result { + match self + .store_slot_result(session_id, prompt, executor_config, source, replace) + .await? + { + StoreQueuedMessageResult::Stored(row) => Ok(row), + StoreQueuedMessageResult::Conflict(row) => Ok(row), + } + } + + async fn store_slot_result( + &self, + session_id: Uuid, + prompt: &str, + executor_config: &ExecutorConfig, + source: QueuedMessageSource, + replace: bool, + ) -> Result { + let config = serde_json::to_string(executor_config)?; + Ok(SessionQueuedMessage::store( + &self.db.pool, + session_id, + prompt, + Some(&config), + source, + replace, + ) + .await?) + } + + async fn paste_slot( + &self, + session: &Session, + row: SessionQueuedMessage, + claude_session_id: Option<&str>, + ) -> Result { + let Some(claimed) = + SessionQueuedMessage::claim(&self.db.pool, row.id, claude_session_id).await? + else { + return Ok(DispatchOutcome::Conflict { + status: self.status(session.id).await?, + }); + }; + if self + .transport + .paste_and_submit(session.workspace_id, &claimed.prompt) + .await + { + SessionQueuedMessage::mark_pasted(&self.db.pool, claimed.id).await?; + let status = self.status(session.id).await?; + Ok(DispatchOutcome::RoutedToCli { delivery: status }) + } else { + SessionQueuedMessage::requeue( + &self.db.pool, + claimed.id, + Some("CLI paste failed; queued for retry"), + ) + .await?; + Ok(DispatchOutcome::Queued { + status: self.status(session.id).await?, + }) + } + } + + async fn dispatch_executor_locked( + &self, + session: &Session, + prompt: &str, + executor_config: &ExecutorConfig, + source: QueuedMessageSource, + existing: Option, + ) -> Result { + let reservation = match WorkspaceSpawnReservation::acquire( + &self.db.pool, + session.workspace_id, + SpawnReservationHolder::Executor, + ) + .await + { + Ok(Some(reservation)) => reservation, + Ok(None) => { + return self + .queue_or_status(session.id, prompt, executor_config, source, existing) + .await; + } + Err(error) => { + tracing::warn!(?error, session_id = %session.id, "spawn reservation failed closed"); + return self + .queue_or_status(session.id, prompt, executor_config, source, existing) + .await; + } + }; + + let claimed = if let Some(row) = existing { + match SessionQueuedMessage::claim(&self.db.pool, row.id, None).await? { + Some(row) => Some(row), + None => { + let _ = WorkspaceSpawnReservation::release( + &self.db.pool, + session.workspace_id, + &reservation.fence, + ) + .await; + return Ok(DispatchOutcome::Conflict { + status: self.status(session.id).await?, + }); + } + } + } else { + None + }; + + let dispatched = self + .dispatcher + .dispatch(session, prompt, executor_config) + .await; + if let Err(error) = WorkspaceSpawnReservation::release( + &self.db.pool, + session.workspace_id, + &reservation.fence, + ) + .await + { + tracing::warn!(?error, workspace_id = %session.workspace_id, "failed to release executor spawn reservation"); + } + + match dispatched { + Ok(execution_process) => { + if let Some(row) = claimed { + SessionQueuedMessage::mark_consumed(&self.db.pool, row.id).await?; + } + Ok(DispatchOutcome::Started { execution_process }) + } + Err(error) => { + tracing::warn!(?error, session_id = %session.id, "executor dispatch failed; preserving prompt in queue"); + if let Some(row) = claimed { + SessionQueuedMessage::requeue( + &self.db.pool, + row.id, + Some("executor start failed; queued for retry"), + ) + .await?; + Ok(DispatchOutcome::Queued { + status: self.status(session.id).await?, + }) + } else { + self.queue_or_status(session.id, prompt, executor_config, source, None) + .await + } + } + } + } + + fn status_from_row(row: SessionQueuedMessage) -> Result { + Ok(QueueStatus::from_message(QueuedMessage::try_from(row)?)) + } + + pub async fn on_executor_finished(&self, session_id: Uuid) -> Result { + let session = Session::find_by_id(&self.db.pool, session_id) + .await? + .ok_or(CliCollabError::WorkspaceMissing(session_id))?; + let binding = match CliPaneBinding::find_active_for_session(&self.db.pool, session_id).await + { + Ok(binding) => binding, + Err(error) => { + tracing::warn!(?error, %session_id, "finish hook pane lookup failed closed"); + return Ok(false); + } + }; + if let Some(binding) = binding + && let Some(sid) = binding.claude_session_id + && self.transport.pane_alive(session.workspace_id).await + { + self.transport + .signal_resume_ready(session.workspace_id, &sid) + .await; + // Give the 1 s bootstrap poll a chance to exec the resumed TUI; + // the durable queue remains untouched during this transition. + self.notify.notify_one(); + return Ok(false); + } + let started = self.drain_session(session_id).await?; + self.notify.notify_one(); + Ok(started) + } + + async fn drain_session(&self, session_id: Uuid) -> Result { + if self.routing_disabled { + return Ok(false); + } + let lock = self.session_lock(session_id).await; + let _guard = lock.lock().await; + let Some(row) = SessionQueuedMessage::find_active(&self.db.pool, session_id).await? else { + return Ok(false); + }; + if row.state != QueuedMessageState::Queued { + return Ok(false); + } + let session = Session::find_by_id(&self.db.pool, session_id) + .await? + .ok_or(CliCollabError::WorkspaceMissing(session_id))?; + let executor_config = match row.parsed_executor_config() { + Ok(Some(config)) => config, + Ok(None) => { + SessionQueuedMessage::set_failure_reason( + &self.db.pool, + row.id, + "queued message has no executor configuration", + ) + .await?; + return Ok(false); + } + Err(error) => { + SessionQueuedMessage::set_failure_reason( + &self.db.pool, + row.id, + "queued message has invalid executor configuration", + ) + .await?; + tracing::warn!(?error, queue_id = %row.id, "invalid durable queue executor config"); + return Ok(false); + } + }; + match self.derive_lease_locked(&session).await { + WriterLease::Executor | WriterLease::CliAmbiguous | WriterLease::Busy => Ok(false), + WriterLease::Cli { claude_session_id } => { + let routed = self + .paste_slot(&session, row, claude_session_id.as_deref()) + .await?; + Ok(matches!(routed, DispatchOutcome::RoutedToCli { .. })) + } + WriterLease::Free => { + let prompt = row.prompt.clone(); + let source = row.source; + let outcome = self + .dispatch_executor_locked( + &session, + &prompt, + &executor_config, + source, + Some(row), + ) + .await?; + Ok(matches!(outcome, DispatchOutcome::Started { .. })) + } + } + } + + async fn reconcile_delivery_state(&self) { + match SessionQueuedMessage::reconcile( + &self.db.pool, + Utc::now(), + PASTING_STARTUP_GRACE, + PASTE_ACK_TIMEOUT, + ) + .await + { + Ok(reconciled) + if reconciled.imported > 0 + || reconciled.requeued_pasting > 0 + || reconciled.requeued_pasted > 0 => + { + tracing::info!( + imported = reconciled.imported, + requeued_pasting = reconciled.requeued_pasting, + requeued_pasted = reconciled.requeued_pasted, + "reconciled durable CLI delivery state" + ); + } + Ok(_) => {} + Err(error) => tracing::warn!(?error, "failed to reconcile CLI delivery state"), + } + } + + async fn drain_all(&self) { + self.reconcile_delivery_state().await; + if self.routing_disabled { + return; + } + let rows = match SessionQueuedMessage::list_active(&self.db.pool).await { + Ok(rows) => rows, + Err(error) => { + tracing::warn!(?error, "durable CLI queue scan failed closed"); + return; + } + }; + for session_id in rows + .into_iter() + .filter(|row| row.state == QueuedMessageState::Queued) + .map(|row| row.session_id) + { + if let Err(error) = self.drain_session(session_id).await { + tracing::warn!(?error, %session_id, "durable CLI queue drain failed"); + } + } + } + + async fn run_drain(self: Arc) { + self.reconcile_delivery_state().await; + let mut interval = tokio::time::interval(DRAIN_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + _ = self.shutdown.cancelled() => break, + _ = interval.tick() => self.drain_all().await, + _ = self.notify.notified() => self.drain_all().await, + } + } + } + + async fn run_ingest_wakeup( + self: Arc, + mut updates: tokio::sync::broadcast::Receiver< + super::claude_transcript_ingest::NativeFeedUpdate, + >, + ) { + loop { + tokio::select! { + _ = self.shutdown.cancelled() => break, + update = updates.recv() => match update { + Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + self.notify.notify_one(); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + } + } +} + +enum PreparedSlot { + None, + Stored(SessionQueuedMessage), + Conflict(QueueStatus), +} + +/// Build the executor's effective cwd without importing deployment code. +pub async fn effective_dir_for_session( + db: &DBService, + session: &Session, +) -> Result, sqlx::Error> { + let Some(workspace) = Workspace::find_by_id(&db.pool, session.workspace_id).await? else { + return Ok(None); + }; + Ok(workspace + .container_ref + .as_deref() + .and_then(|root| session.effective_working_dir(Path::new(root)))) +} + +/// Kept here so local dispatcher implementations and server validation use the +/// same run reason without restating its storage spelling. +pub const COLLAB_RUN_REASON: ExecutionProcessRunReason = ExecutionProcessRunReason::CodingAgent; + +#[cfg(test)] +mod tests { + use std::sync::Mutex as StdMutex; + + use db::models::{ + claude_session_link::ClaudeSessionLink, + cli_pane_binding::{CliPaneBinding, CliPaneBoundVia}, + execution_process::{CreateExecutionProcess, ExecutionProcessRunReason}, + session::CreateSession, + workspace::CreateWorkspace, + }; + use executors::{ + actions::{ + ExecutorAction, ExecutorActionType, coding_agent_initial::CodingAgentInitialRequest, + }, + executors::BaseCodingAgent, + }; + use sqlx::sqlite::SqlitePoolOptions; + + use super::*; + + #[derive(Clone)] + struct FakeProbe(Arc>); + + #[async_trait] + impl CliWriterProbe for FakeProbe { + async fn probe( + &self, + _workspace_id: Uuid, + _effective_dir: &Path, + _expected_sid: Option<&str>, + ) -> ProbeReport { + self.0.lock().unwrap().clone() + } + } + + struct FakeTransport; + + #[async_trait] + impl CliPasteTransport for FakeTransport { + async fn paste_and_submit(&self, _workspace_id: Uuid, _text: &str) -> bool { + true + } + + async fn pane_alive(&self, _workspace_id: Uuid) -> bool { + true + } + + async fn agent_running(&self, _workspace_id: Uuid) -> Option { + Some(true) + } + + async fn signal_resume_ready(&self, _workspace_id: Uuid, _sid: &str) {} + } + + struct NeverDispatcher; + + #[async_trait] + impl CliExecutorDispatcher for NeverDispatcher { + async fn dispatch( + &self, + _session: &Session, + _prompt: &str, + _executor_config: &ExecutorConfig, + ) -> AnyhowResult { + panic!("lease-only tests must not dispatch") + } + } + + async fn fixture() -> (DBService, Workspace, Session) { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + db::run_migrations_for_tests(&pool).await.unwrap(); + let db = DBService { pool }; + let workspace_id = Uuid::new_v4(); + Workspace::create( + &db.pool, + &CreateWorkspace { + branch: "main".to_string(), + name: Some("collaboration fixture".to_string()), + }, + workspace_id, + ) + .await + .unwrap(); + Workspace::update_container_ref(&db.pool, workspace_id, "/tmp/collab-fixture") + .await + .unwrap(); + let workspace = Workspace::find_by_id(&db.pool, workspace_id) + .await + .unwrap() + .unwrap(); + let session = Session::create( + &db.pool, + &CreateSession { + executor: Some("CLAUDE_CODE".to_string()), + name: None, + }, + Uuid::new_v4(), + workspace_id, + ) + .await + .unwrap(); + (db, workspace, session) + } + + fn service(db: DBService, report: ProbeReport) -> (CliCollabService, FakeProbe) { + let probe = FakeProbe(Arc::new(StdMutex::new(report))); + ( + CliCollabService { + db, + probe: Arc::new(probe.clone()), + transport: Arc::new(FakeTransport), + dispatcher: Arc::new(NeverDispatcher), + ingest: None, + session_locks: Mutex::new(HashMap::new()), + notify: Notify::new(), + routing_disabled: false, + shutdown: CancellationToken::new(), + }, + probe, + ) + } + + fn report( + pane_session_exists: bool, + agent_running: Option, + sid_evidence: SidEvidence, + ) -> ProbeReport { + ProbeReport { + pane_session_exists, + agent_running, + sid_evidence, + probe_failed: false, + only_active_claude_in_cwd: None, + } + } + + #[tokio::test] + async fn lease_derivation_covers_executor_cli_ambiguous_free_and_db_failure() { + let (db, workspace, session) = fixture().await; + let (service, probe) = + service(db.clone(), report(false, Some(false), SidEvidence::Unknown)); + assert_eq!(service.derive_lease(&session).await, WriterLease::Free); + + CliPaneBinding::record_launch( + &db.pool, + workspace.id, + session.id, + Some("11111111-1111-4111-8111-111111111111"), + CliPaneBoundVia::CliResume, + ) + .await + .unwrap(); + ClaudeSessionLink::assign_manual( + &db.pool, + "11111111-1111-4111-8111-111111111111", + session.id, + "/tmp/collab-fixture", + ) + .await + .unwrap() + .unwrap(); + *probe.0.lock().unwrap() = report( + true, + Some(true), + SidEvidence::ConfirmedResume("11111111-1111-4111-8111-111111111111".to_string()), + ); + assert_eq!( + service.derive_lease(&session).await, + WriterLease::Cli { + claude_session_id: Some("11111111-1111-4111-8111-111111111111".to_string()) + } + ); + + *probe.0.lock().unwrap() = report(true, Some(true), SidEvidence::Ambiguous); + assert_eq!( + service.derive_lease(&session).await, + WriterLease::CliAmbiguous + ); + + let action = ExecutorAction::new( + ExecutorActionType::CodingAgentInitialRequest(CodingAgentInitialRequest { + prompt: "running".to_string(), + executor_config: ExecutorConfig::new(BaseCodingAgent::ClaudeCode), + working_dir: None, + }), + None, + ); + ExecutionProcess::create( + &db.pool, + &CreateExecutionProcess { + session_id: session.id, + executor_action: action, + run_reason: ExecutionProcessRunReason::CodingAgent, + }, + Uuid::new_v4(), + &[], + ) + .await + .unwrap(); + assert_eq!(service.derive_lease(&session).await, WriterLease::Executor); + + db.pool.close().await; + assert_eq!(service.derive_lease(&session).await, WriterLease::Busy); + } +} diff --git a/crates/services/src/services/mod.rs b/crates/services/src/services/mod.rs index 4eabc66a2f2..c2b2cb5108d 100644 --- a/crates/services/src/services/mod.rs +++ b/crates/services/src/services/mod.rs @@ -2,6 +2,7 @@ pub mod analytics; pub mod approvals; pub mod auth; pub mod claude_transcript_ingest; +pub mod cli_collab; pub mod config; pub mod container; pub mod diff_stream; From 9b2ea03291c64b62461a24a988253b1f85acaf17 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 13:01:40 +0000 Subject: [PATCH 04/36] feat(collab): paste ack, foreign-writer detection, busy-notice, launch bindings, gated dispatch Cross-cutting completion of the Phase 2 backend: same-transaction paste-ack matcher (NativeImportContext + bound_queued_message_id), foreign-writer detection, cli-fresh auto-bind with quarantine fallback, writer-probe and paste-transport impls, busy-wait bootstrap (resume-ready file + TTL), fail-closed sid guard, launch-time pane bindings + spawn reservations, and follow_up routed through the dispatch gate (DispatchOutcome API). Known gaps closed in follow-up commits: ingest-ack test suite (helpers staged), cli_collab unit tests, clippy strictness, shared/types.ts regeneration. --- ...4f89be9510689ae8256168ebd4d917f021b14.json | 12 + ...718a81ba5d080a5e6e1a6b9150359f6249eb1.json | 20 ++ ...7c81bf43a08ce4fc17a4ee781ea92f50b7c0d.json | 12 + ...5f9d8a3af06545cc297a946edf16a06945484.json | 20 ++ ...0906a4178424bebb1fbec3cee93f894018662.json | 20 ++ ...19bafef6d2757d60b418780dbb849c0d19eb.json} | 6 +- crates/db/src/models/claude_session_link.rs | 37 +++ crates/db/src/models/cli_native_record.rs | 269 +++++++++++++++++- crates/deployment/src/lib.rs | 3 + crates/local-deployment/src/container.rs | 167 +++++------ crates/local-deployment/src/lib.rs | 27 +- .../local-deployment/src/paste_transport.rs | 36 +++ crates/local-deployment/src/pty.rs | 262 +++++++++++++++-- crates/local-deployment/src/writer_probe.rs | 211 ++++++++++++++ crates/server/src/bin/generate_types.rs | 10 + crates/server/src/error.rs | 4 + crates/server/src/routes/sessions/mod.rs | 209 ++++++++++---- crates/server/src/routes/terminal.rs | 184 ++++++++++-- .../src/services/claude_transcript_ingest.rs | 154 ++++++++-- .../claude_transcript_ingest/projection.rs | 5 +- .../claude_transcript_ingest/tests.rs | 111 +++++++- crates/services/src/services/cli_collab.rs | 22 +- 22 files changed, 1563 insertions(+), 238 deletions(-) create mode 100644 crates/db/.sqlx/query-17e0e0cd01625e3dfd9ba9ffc994f89be9510689ae8256168ebd4d917f021b14.json create mode 100644 crates/db/.sqlx/query-34f46a00600ed636b8f37ccfdbc718a81ba5d080a5e6e1a6b9150359f6249eb1.json create mode 100644 crates/db/.sqlx/query-516436678641b5339d7c9e730617c81bf43a08ce4fc17a4ee781ea92f50b7c0d.json create mode 100644 crates/db/.sqlx/query-67ef0c3fb9a8288ca9928778d8d5f9d8a3af06545cc297a946edf16a06945484.json create mode 100644 crates/db/.sqlx/query-d9c43426a85d266f1a82d29a2920906a4178424bebb1fbec3cee93f894018662.json rename crates/db/.sqlx/{query-8d777ff2bbf22db748cd39dea9d2ab743d3e27ff7300fa47ece8788616a83d35.json => query-e8168083a38882d5806b8b0cbb6419bafef6d2757d60b418780dbb849c0d19eb.json} (53%) create mode 100644 crates/local-deployment/src/paste_transport.rs create mode 100644 crates/local-deployment/src/writer_probe.rs diff --git a/crates/db/.sqlx/query-17e0e0cd01625e3dfd9ba9ffc994f89be9510689ae8256168ebd4d917f021b14.json b/crates/db/.sqlx/query-17e0e0cd01625e3dfd9ba9ffc994f89be9510689ae8256168ebd4d917f021b14.json new file mode 100644 index 00000000000..fcc23df02cf --- /dev/null +++ b/crates/db/.sqlx/query-17e0e0cd01625e3dfd9ba9ffc994f89be9510689ae8256168ebd4d917f021b14.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'imported',\n claude_session_id = COALESCE(claude_session_id, $1),\n acked_at = $2,\n updated_at = $2\n WHERE id = $3 AND state IN ('pasting', 'pasted')", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "17e0e0cd01625e3dfd9ba9ffc994f89be9510689ae8256168ebd4d917f021b14" +} diff --git a/crates/db/.sqlx/query-34f46a00600ed636b8f37ccfdbc718a81ba5d080a5e6e1a6b9150359f6249eb1.json b/crates/db/.sqlx/query-34f46a00600ed636b8f37ccfdbc718a81ba5d080a5e6e1a6b9150359f6249eb1.json new file mode 100644 index 00000000000..91d593ec38d --- /dev/null +++ b/crates/db/.sqlx/query-34f46a00600ed636b8f37ccfdbc718a81ba5d080a5e6e1a6b9150359f6249eb1.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: Uuid\"\n FROM session_queued_messages\n WHERE session_id = $1\n AND state IN ('pasting', 'pasted')\n AND prompt = $2\n AND (claude_session_id IS NULL OR claude_session_id = $3)\n AND pasted_at IS NOT NULL\n AND pasted_at >= $4\n AND pasted_at <= $5\n ORDER BY pasted_at DESC\n LIMIT 1", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 5 + }, + "nullable": [ + true + ] + }, + "hash": "34f46a00600ed636b8f37ccfdbc718a81ba5d080a5e6e1a6b9150359f6249eb1" +} diff --git a/crates/db/.sqlx/query-516436678641b5339d7c9e730617c81bf43a08ce4fc17a4ee781ea92f50b7c0d.json b/crates/db/.sqlx/query-516436678641b5339d7c9e730617c81bf43a08ce4fc17a4ee781ea92f50b7c0d.json new file mode 100644 index 00000000000..5c06ecb0e5f --- /dev/null +++ b/crates/db/.sqlx/query-516436678641b5339d7c9e730617c81bf43a08ce4fc17a4ee781ea92f50b7c0d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE claude_session_links\n SET foreign_writer_seen_at = $1\n WHERE claude_session_id = $2 AND session_id = $3", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "516436678641b5339d7c9e730617c81bf43a08ce4fc17a4ee781ea92f50b7c0d" +} diff --git a/crates/db/.sqlx/query-67ef0c3fb9a8288ca9928778d8d5f9d8a3af06545cc297a946edf16a06945484.json b/crates/db/.sqlx/query-67ef0c3fb9a8288ca9928778d8d5f9d8a3af06545cc297a946edf16a06945484.json new file mode 100644 index 00000000000..1296745dae7 --- /dev/null +++ b/crates/db/.sqlx/query-67ef0c3fb9a8288ca9928778d8d5f9d8a3af06545cc297a946edf16a06945484.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(\n SELECT 1 FROM execution_processes\n WHERE session_id = $1\n AND status = 'running'\n AND run_reason = 'codingagent'\n ) AS \"running!: bool\"", + "describe": { + "columns": [ + { + "name": "running!: bool", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "67ef0c3fb9a8288ca9928778d8d5f9d8a3af06545cc297a946edf16a06945484" +} diff --git a/crates/db/.sqlx/query-d9c43426a85d266f1a82d29a2920906a4178424bebb1fbec3cee93f894018662.json b/crates/db/.sqlx/query-d9c43426a85d266f1a82d29a2920906a4178424bebb1fbec3cee93f894018662.json new file mode 100644 index 00000000000..2e00fd360bb --- /dev/null +++ b/crates/db/.sqlx/query-d9c43426a85d266f1a82d29a2920906a4178424bebb1fbec3cee93f894018662.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT MAX(foreign_writer_seen_at) AS \"seen_at: DateTime\"\n FROM claude_session_links\n WHERE session_id = $1", + "describe": { + "columns": [ + { + "name": "seen_at: DateTime", + "ordinal": 0, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true + ] + }, + "hash": "d9c43426a85d266f1a82d29a2920906a4178424bebb1fbec3cee93f894018662" +} diff --git a/crates/db/.sqlx/query-8d777ff2bbf22db748cd39dea9d2ab743d3e27ff7300fa47ece8788616a83d35.json b/crates/db/.sqlx/query-e8168083a38882d5806b8b0cbb6419bafef6d2757d60b418780dbb849c0d19eb.json similarity index 53% rename from crates/db/.sqlx/query-8d777ff2bbf22db748cd39dea9d2ab743d3e27ff7300fa47ece8788616a83d35.json rename to crates/db/.sqlx/query-e8168083a38882d5806b8b0cbb6419bafef6d2757d60b418780dbb849c0d19eb.json index 39872107ac1..3619180101c 100644 --- a/crates/db/.sqlx/query-8d777ff2bbf22db748cd39dea9d2ab743d3e27ff7300fa47ece8788616a83d35.json +++ b/crates/db/.sqlx/query-e8168083a38882d5806b8b0cbb6419bafef6d2757d60b418780dbb849c0d19eb.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "INSERT OR IGNORE INTO cli_native_records\n (file_id, line_seq, claude_session_id, uuid,\n parent_uuid, kind, ts, raw,\n disposition, bound_coding_agent_turn_id)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "query": "INSERT OR IGNORE INTO cli_native_records\n (file_id, line_seq, claude_session_id, uuid,\n parent_uuid, kind, ts, raw,\n disposition, bound_coding_agent_turn_id,\n bound_queued_message_id)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", "describe": { "columns": [], "parameters": { - "Right": 10 + "Right": 11 }, "nullable": [] }, - "hash": "8d777ff2bbf22db748cd39dea9d2ab743d3e27ff7300fa47ece8788616a83d35" + "hash": "e8168083a38882d5806b8b0cbb6419bafef6d2757d60b418780dbb849c0d19eb" } diff --git a/crates/db/src/models/claude_session_link.rs b/crates/db/src/models/claude_session_link.rs index 254d4f24c51..73c26be0872 100644 --- a/crates/db/src/models/claude_session_link.rs +++ b/crates/db/src/models/claude_session_link.rs @@ -166,6 +166,43 @@ impl ClaudeSessionLink { Ok(Some(mutation)) } + pub async fn assign_cli( + pool: &SqlitePool, + claude_session_id: &str, + session_id: Uuid, + workspace_id: Uuid, + cwd: &str, + bound_via: ClaudeSessionBoundVia, + ) -> Result { + debug_assert!(matches!( + bound_via, + ClaudeSessionBoundVia::CliResume | ClaudeSessionBoundVia::CliFresh + )); + Self::upsert( + pool, + claude_session_id, + session_id, + workspace_id, + cwd, + bound_via, + ) + .await + } + + pub async fn latest_foreign_writer_seen_for_session( + pool: &SqlitePool, + session_id: Uuid, + ) -> Result>, sqlx::Error> { + sqlx::query_scalar!( + r#"SELECT MAX(foreign_writer_seen_at) AS "seen_at: DateTime" + FROM claude_session_links + WHERE session_id = $1"#, + session_id + ) + .fetch_one(pool) + .await + } + async fn upsert( pool: &SqlitePool, claude_session_id: &str, diff --git a/crates/db/src/models/cli_native_record.rs b/crates/db/src/models/cli_native_record.rs index 5d4b2200484..5ae8e40fb80 100644 --- a/crates/db/src/models/cli_native_record.rs +++ b/crates/db/src/models/cli_native_record.rs @@ -74,6 +74,14 @@ pub struct ImportBatchResult { pub last_seq: i64, } +/// Fresh, deployment-owned evidence captured immediately before an import. +/// `false` is the fail-closed default: an unreadable pane must never be +/// classified as a foreign writer. +#[derive(Debug, Clone, Copy, Default)] +pub struct NativeImportContext { + pub app_pane_absent: bool, +} + #[derive(Debug, Clone)] pub struct ReplacedGenerationImport { pub file: CliNativeFile, @@ -100,7 +108,69 @@ pub struct SessionNativeRecord { pub generation: i64, } +#[derive(Debug, Clone, FromRow)] +pub struct NativeBranchPathRecord { + pub claude_session_id: String, + pub uuid: Option, + pub kind: String, + pub raw: String, + pub depth: i64, +} + impl CliNativeRecord { + pub async fn dropped_branch_path( + pool: &SqlitePool, + session_id: Uuid, + fork_parent_uuid: &str, + branch_leaf_uuid: &str, + ) -> Result>, sqlx::Error> { + let rows = sqlx::query_as::<_, NativeBranchPathRecord>( + r#"WITH RECURSIVE path( + file_id, claude_session_id, uuid, parent_uuid, + kind, raw, depth + ) AS ( + SELECT r.file_id, r.claude_session_id, r.uuid, + r.parent_uuid, r.kind, r.raw, 0 + FROM cli_native_records r + JOIN cli_native_files f ON f.id = r.file_id + JOIN claude_session_links l + ON l.claude_session_id = r.claude_session_id + WHERE l.session_id = ? + AND r.uuid = ? + AND f.generation = ( + SELECT MAX(newer.generation) + FROM cli_native_files newer + WHERE newer.dir_path = f.dir_path + AND newer.file_name = f.file_name + ) + UNION ALL + SELECT parent.file_id, parent.claude_session_id, + parent.uuid, parent.parent_uuid, + parent.kind, parent.raw, child.depth + 1 + FROM cli_native_records parent + JOIN path child + ON parent.file_id = child.file_id + AND parent.uuid = child.parent_uuid + WHERE child.uuid != ? AND child.depth < 4096 + ) + SELECT claude_session_id, uuid, kind, raw, depth + FROM path + ORDER BY depth DESC"#, + ) + .bind(session_id) + .bind(branch_leaf_uuid) + .bind(fork_parent_uuid) + .fetch_all(pool) + .await?; + if !rows + .iter() + .any(|record| record.uuid.as_deref() == Some(fork_parent_uuid)) + { + return Ok(None); + } + Ok(Some(rows)) + } + pub async fn session_ids_for_uuid( pool: &SqlitePool, native_uuid: &str, @@ -125,10 +195,32 @@ impl CliNativeRecord { file_id: Uuid, records: &[NewCliNativeRecord], cursor: &ImportedCursor<'_>, + ) -> Result { + let mut tx = pool.begin().await?; + let result = Self::import_batch_in_transaction( + &mut tx, + file_id, + records, + cursor, + None, + NativeImportContext::default(), + ) + .await?; + tx.commit().await?; + Ok(result) + } + + pub async fn import_batch_with_context( + pool: &SqlitePool, + file_id: Uuid, + records: &[NewCliNativeRecord], + cursor: &ImportedCursor<'_>, + context: NativeImportContext, ) -> Result { let mut tx = pool.begin().await?; let result = - Self::import_batch_in_transaction(&mut tx, file_id, records, cursor, None).await?; + Self::import_batch_in_transaction(&mut tx, file_id, records, cursor, None, context) + .await?; tx.commit().await?; Ok(result) } @@ -202,9 +294,15 @@ impl CliNativeRecord { .execute(&mut *tx) .await?; - let imported = - Self::import_batch_in_transaction(&mut tx, file_id, records, cursor, next_outbox_seq) - .await?; + let imported = Self::import_batch_in_transaction( + &mut tx, + file_id, + records, + cursor, + next_outbox_seq, + NativeImportContext::default(), + ) + .await?; tx.commit().await?; let file = CliNativeFile::find_by_id(pool, file_id) @@ -213,12 +311,88 @@ impl CliNativeRecord { Ok(ReplacedGenerationImport { file, imported }) } + pub async fn replace_generation_and_import_batch_with_context( + pool: &SqlitePool, + registration: &RegisterCliNativeFile<'_>, + records: &[NewCliNativeRecord], + cursor: &ImportedCursor<'_>, + context: NativeImportContext, + ) -> Result { + let mut tx = pool.begin().await?; + let next_generation = sqlx::query_scalar!( + r#"SELECT COALESCE(MAX(generation), -1) + 1 AS "generation!: i64" + FROM cli_native_files + WHERE dir_path = $1 AND file_name = $2"#, + registration.dir_path, + registration.file_name + ) + .fetch_one(&mut *tx) + .await?; + let session_id = sqlx::query_scalar!( + r#"SELECT session_id AS "session_id!: Uuid" + FROM claude_session_links + WHERE claude_session_id = $1"#, + registration.claude_session_id + ) + .fetch_optional(&mut *tx) + .await?; + let next_outbox_seq = if let Some(session_id) = session_id { + Some(CliIngestOutbox::next_seq_in_transaction(&mut tx, session_id).await?) + } else { + None + }; + let file_id = Uuid::new_v4(); + sqlx::query!( + r#"INSERT INTO cli_native_files + (id, claude_session_id, dir_path, file_name, + discovered_workspace_id, dev, inode, generation, + observed_size, observed_mtime_ms) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"#, + file_id, + registration.claude_session_id, + registration.dir_path, + registration.file_name, + registration.discovered_workspace_id, + registration.dev, + registration.inode, + next_generation, + registration.observed_size, + registration.observed_mtime_ms + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + r#"DELETE FROM cli_native_files + WHERE dir_path = $1 AND file_name = $2 AND id != $3"#, + registration.dir_path, + registration.file_name, + file_id + ) + .execute(&mut *tx) + .await?; + let imported = Self::import_batch_in_transaction( + &mut tx, + file_id, + records, + cursor, + next_outbox_seq, + context, + ) + .await?; + tx.commit().await?; + let file = CliNativeFile::find_by_id(pool, file_id) + .await? + .expect("activated native generation exists"); + Ok(ReplacedGenerationImport { file, imported }) + } + async fn import_batch_in_transaction( tx: &mut Transaction<'_, Sqlite>, file_id: Uuid, records: &[NewCliNativeRecord], cursor: &ImportedCursor<'_>, minimum_next_outbox_seq: Option, + context: NativeImportContext, ) -> Result { let imported_at = Utc::now(); @@ -305,13 +479,48 @@ impl CliNativeRecord { None }; + let bound_queued_message_id = + if record.kind == "user" && !linked_to_execution && bound_turn_id.is_none() { + match (record.user_prompt.as_deref(), session_id) { + (Some(prompt), Some(session_id)) => { + let reference_time = record.recorded_at.unwrap_or(imported_at); + let earliest_paste = reference_time - chrono::Duration::minutes(15); + let latest_paste = reference_time + chrono::Duration::seconds(5); + sqlx::query_scalar!( + r#"SELECT id AS "id!: Uuid" + FROM session_queued_messages + WHERE session_id = $1 + AND state IN ('pasting', 'pasted') + AND prompt = $2 + AND (claude_session_id IS NULL OR claude_session_id = $3) + AND pasted_at IS NOT NULL + AND pasted_at >= $4 + AND pasted_at <= $5 + ORDER BY pasted_at DESC + LIMIT 1"#, + session_id, + prompt, + record.claude_session_id, + earliest_paste, + latest_paste + ) + .fetch_optional(&mut **tx) + .await? + } + _ => None, + } + } else { + None + }; + let disposition = record.disposition.as_str(); let inserted = sqlx::query!( r#"INSERT OR IGNORE INTO cli_native_records (file_id, line_seq, claude_session_id, uuid, parent_uuid, kind, ts, raw, - disposition, bound_coding_agent_turn_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"#, + disposition, bound_coding_agent_turn_id, + bound_queued_message_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)"#, file_id, record.line_seq, record.claude_session_id, @@ -321,7 +530,8 @@ impl CliNativeRecord { record.ts, record.raw, disposition, - bound_turn_id + bound_turn_id, + bound_queued_message_id ) .execute(&mut **tx) .await?; @@ -330,6 +540,51 @@ impl CliNativeRecord { continue; } + if let Some(queue_id) = bound_queued_message_id { + sqlx::query!( + r#"UPDATE session_queued_messages SET + state = 'imported', + claude_session_id = COALESCE(claude_session_id, $1), + acked_at = $2, + updated_at = $2 + WHERE id = $3 AND state IN ('pasting', 'pasted')"#, + record.claude_session_id, + imported_at, + queue_id + ) + .execute(&mut **tx) + .await?; + } else if context.app_pane_absent + && record.kind == "user" + && !linked_to_execution + && bound_turn_id.is_none() + && let Some(session_id) = session_id + { + let executor_running = sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM execution_processes + WHERE session_id = $1 + AND status = 'running' + AND run_reason = 'codingagent' + ) AS "running!: bool""#, + session_id + ) + .fetch_one(&mut **tx) + .await?; + if !executor_running { + sqlx::query!( + r#"UPDATE claude_session_links + SET foreign_writer_seen_at = $1 + WHERE claude_session_id = $2 AND session_id = $3"#, + imported_at, + record.claude_session_id, + session_id + ) + .execute(&mut **tx) + .await?; + } + } + result.inserted_records += 1; let Some(session_id) = session_id else { continue; diff --git a/crates/deployment/src/lib.rs b/crates/deployment/src/lib.rs index d2a8312f482..b09501be56f 100644 --- a/crates/deployment/src/lib.rs +++ b/crates/deployment/src/lib.rs @@ -18,6 +18,7 @@ use services::services::{ approvals::Approvals, auth::AuthContext, claude_transcript_ingest::ClaudeTranscriptIngest, + cli_collab::CliCollabService, config::{Config, ConfigError}, container::{ContainerError, ContainerService}, events::{EventError, EventService}, @@ -109,6 +110,8 @@ pub trait Deployment: Clone + Send + Sync + 'static { fn claude_transcript_ingest(&self) -> Option<&Arc>; + fn cli_collab(&self) -> &Arc; + fn auth_context(&self) -> &AuthContext; fn relay_control(&self) -> &Arc; diff --git a/crates/local-deployment/src/container.rs b/crates/local-deployment/src/container.rs index 2d2084eac0a..72587e4ab53 100644 --- a/crates/local-deployment/src/container.rs +++ b/crates/local-deployment/src/container.rs @@ -2,7 +2,7 @@ use std::{ collections::HashMap, io, path::{Path, PathBuf}, - sync::Arc, + sync::{Arc, OnceLock}, time::{Duration, Instant}, }; @@ -42,12 +42,12 @@ use serde_json::json; use services::services::{ analytics::AnalyticsContext, approvals::{Approvals, executor_approvals::ExecutorApprovalBridge}, + cli_collab::{CliCollabService, CliExecutorDispatcher}, config::{Config, DEFAULT_COMMIT_REMINDER_PROMPT}, container::{ContainerError, ContainerRef, ContainerService}, diff_stream::{self, DiffStreamHandle}, file::FileService, notification::NotificationService, - queued_message::QueuedMessageService, remote_client::RemoteClient, remote_sync, }; @@ -82,7 +82,7 @@ pub struct LocalContainerService { file_service: FileService, analytics: Option, approvals: Approvals, - queued_message_service: QueuedMessageService, + cli_collab: Arc>>, notification_service: NotificationService, remote_client: Option, } @@ -98,7 +98,6 @@ impl LocalContainerService { file_service: FileService, analytics: Option, approvals: Approvals, - queued_message_service: QueuedMessageService, remote_client: Option, ) -> Self { let child_store = Arc::new(RwLock::new(HashMap::new())); @@ -122,7 +121,7 @@ impl LocalContainerService { file_service, analytics, approvals, - queued_message_service, + cli_collab: Arc::new(OnceLock::new()), notification_service, remote_client, }; @@ -132,6 +131,13 @@ impl LocalContainerService { container } + pub fn set_cli_collab(&self, cli_collab: Arc) { + assert!( + self.cli_collab.set(cli_collab).is_ok(), + "CLI collaboration service is initialized exactly once" + ); + } + fn map_workspace_manager_error(err: WorkspaceError) -> ContainerError { match err { WorkspaceError::Database(err) => ContainerError::Sqlx(err), @@ -735,61 +741,23 @@ impl LocalContainerService { .ok() .and_then(|action| action.next_action()) .is_some(); - let mut started_queued_follow_up = false; - - // Only execute queued messages if the execution succeeded - // If it failed or was killed, just clear the queue and finalize - let should_execute_queued = !matches!( - ctx.execution_process.status, - ExecutionProcessStatus::Failed | ExecutionProcessStatus::Killed - ); - - if let Ok(Some(queued_msg)) = container - .queued_message_service - .take_queued(ctx.session.id) - .await - { - if should_execute_queued { - tracing::info!( - "Found queued message for session {}, starting follow-up execution", - ctx.session.id - ); - - // Delete the scratch since we're consuming the queued message - if let Err(e) = Scratch::delete( - &db.pool, - ctx.session.id, - &ScratchType::DraftFollowUp, - ) - .await - { - tracing::warn!( - "Failed to delete scratch after consuming queued message: {}", - e - ); + let started_queued_follow_up = match container.cli_collab.get() { + Some(cli_collab) => { + match cli_collab.on_executor_finished(ctx.session.id).await { + Ok(started) => started, + Err(error) => { + tracing::error!( + ?error, + session_id = %ctx.session.id, + "CLI collaboration finish hook failed closed" + ); + false + } } - - // Execute the queued follow-up - if let Err(e) = container - .start_queued_follow_up(&ctx, &queued_msg.data) - .await - { - tracing::error!("Failed to start queued follow-up: {}", e); - // Fall back to finalization if follow-up fails - container.finalize_task(&ctx).await; - } else { - started_queued_follow_up = true; - } - } else { - // Execution failed or was killed - discard the queued message and finalize - tracing::info!( - "Discarding queued message for session {} due to execution status {:?}", - ctx.session.id, - ctx.execution_process.status - ); - container.finalize_task(&ctx).await; } - } else { + None => false, + }; + if !started_queued_follow_up { container.finalize_task(&ctx).await; } @@ -829,35 +797,14 @@ impl LocalContainerService { .unwrap_or(true); if !has_running_agent - && let Ok(Some(queued_msg)) = container - .queued_message_service - .take_queued(ctx.session.id) - .await + && let Some(cli_collab) = container.cli_collab.get() + && let Err(error) = cli_collab.on_executor_finished(ctx.session.id).await { - tracing::info!( - "Parallel setup script finished with queued message for session {}, starting follow-up", - ctx.session.id + tracing::error!( + ?error, + session_id = %ctx.session.id, + "CLI collaboration setup completion hook failed closed" ); - - if let Err(e) = - Scratch::delete(&db.pool, ctx.session.id, &ScratchType::DraftFollowUp) - .await - { - tracing::warn!( - "Failed to delete scratch after consuming queued message: {}", - e - ); - } - - if let Err(e) = container - .start_queued_follow_up(&ctx, &queued_msg.data) - .await - { - tracing::error!( - "Failed to start queued follow-up from setup script completion: {}", - e - ); - } } } @@ -1170,17 +1117,18 @@ impl LocalContainerService { /// Start a follow-up execution from a queued message async fn start_queued_follow_up( &self, - ctx: &ExecutionContext, + workspace: &Workspace, + session: &Session, queued_data: &DraftFollowUpData, ) -> Result { let executor_profile_id = queued_data.executor_config.profile_id(); // Validate executor matches session if session has prior executions let expected_executor: Option = - ExecutionProcess::latest_executor_profile_for_session(&self.db.pool, ctx.session.id) + ExecutionProcess::latest_executor_profile_for_session(&self.db.pool, session.id) .await? .map(|profile| profile.executor.to_string()) - .or_else(|| ctx.session.executor.clone()); + .or_else(|| session.executor.clone()); if let Some(expected) = expected_executor { let actual = executor_profile_id.executor.to_string(); @@ -1189,10 +1137,10 @@ impl LocalContainerService { } } - if ctx.session.executor.is_none() { + if session.executor.is_none() { Session::update_executor( &self.db.pool, - ctx.session.id, + session.id, &executor_profile_id.executor.to_string(), ) .await?; @@ -1200,14 +1148,12 @@ impl LocalContainerService { // Get latest agent turn for session continuity (from coding agent turns) let latest_session_info = - CodingAgentTurn::find_latest_session_info(&self.db.pool, ctx.session.id).await?; + CodingAgentTurn::find_latest_session_info(&self.db.pool, session.id).await?; - let repos = - WorkspaceRepo::find_repos_for_workspace(&self.db.pool, ctx.workspace.id).await?; + let repos = WorkspaceRepo::find_repos_for_workspace(&self.db.pool, workspace.id).await?; let cleanup_action = self.cleanup_actions_for_repos(&repos); - let working_dir = ctx - .session + let working_dir = session .agent_working_dir .as_ref() .filter(|dir| !dir.is_empty()) @@ -1232,8 +1178,8 @@ impl LocalContainerService { let action = ExecutorAction::new(action_type, cleanup_action.map(Box::new)); self.start_execution( - &ctx.workspace, - &ctx.session, + workspace, + session, &action, &ExecutionProcessRunReason::CodingAgent, ) @@ -1241,6 +1187,33 @@ impl LocalContainerService { } } +#[async_trait] +impl CliExecutorDispatcher for LocalContainerService { + async fn dispatch( + &self, + session: &Session, + prompt: &str, + executor_config: &executors::profile::ExecutorConfig, + ) -> anyhow::Result { + let workspace = Workspace::find_by_id(&self.db.pool, session.workspace_id) + .await? + .ok_or_else(|| anyhow!("workspace {} not found", session.workspace_id))?; + let data = DraftFollowUpData { + message: prompt.to_string(), + executor_config: executor_config.clone(), + }; + let process = self + .start_queued_follow_up(&workspace, session, &data) + .await?; + if let Err(error) = + Scratch::delete(&self.db.pool, session.id, &ScratchType::DraftFollowUp).await + { + tracing::warn!(?error, session_id = %session.id, "failed to clear dispatched follow-up scratch"); + } + Ok(process) + } +} + fn failure_exit_status() -> std::process::ExitStatus { #[cfg(unix)] { diff --git a/crates/local-deployment/src/lib.rs b/crates/local-deployment/src/lib.rs index 7a5eae6e355..87f7cd042e5 100644 --- a/crates/local-deployment/src/lib.rs +++ b/crates/local-deployment/src/lib.rs @@ -20,6 +20,7 @@ use services::services::{ approvals::Approvals, auth::AuthContext, claude_transcript_ingest::ClaudeTranscriptIngest, + cli_collab::CliCollabService, config::{Config, load_config_from_file, save_config_to_file}, container::ContainerService, events::EventService, @@ -43,13 +44,18 @@ use uuid::Uuid; use workspace_manager::WorkspaceManager; use worktree_manager::WorktreeManager; -use crate::{container::LocalContainerService, pty::PtyService}; +use crate::{ + container::LocalContainerService, paste_transport::LocalCliPasteTransport, pty::PtyService, + writer_probe::LocalCliWriterProbe, +}; pub mod cli_activity; mod command; pub mod container; mod copy; pub mod loop_supervisor; +mod paste_transport; pub mod pty; +mod writer_probe; #[derive(Clone)] pub struct LocalDeployment { @@ -68,6 +74,7 @@ pub struct LocalDeployment { approvals: Approvals, queued_message_service: QueuedMessageService, claude_transcript_ingest: Option>, + cli_collab: Arc, remote_client: Result, auth_context: AuthContext, oauth_handoffs: Arc>>, @@ -234,7 +241,6 @@ impl Deployment for LocalDeployment { file.clone(), analytics_ctx, approvals.clone(), - queued_message_service.clone(), remote_client.clone().ok(), ) .await; @@ -276,8 +282,18 @@ impl Deployment for LocalDeployment { // Read-only Claude native-store tailing for CLI-authored transcript // backfill and live session feeds. The service owns its watcher guards. + let writer_probe = Arc::new(LocalCliWriterProbe::new(db.clone())); let claude_transcript_ingest = - ClaudeTranscriptIngest::spawn(db.clone(), shutdown.child_token()); + ClaudeTranscriptIngest::spawn(db.clone(), writer_probe.clone(), shutdown.child_token()); + let cli_collab = CliCollabService::spawn( + db.clone(), + writer_probe, + Arc::new(LocalCliPasteTransport), + Arc::new(container.clone()), + claude_transcript_ingest.clone(), + shutdown.child_token(), + ); + container.set_cli_collab(cli_collab.clone()); // Keep opted-in CLI loops going across usage/rate limits: detect the // limit banner, schedule a wake-up, and re-prompt the agent. @@ -299,6 +315,7 @@ impl Deployment for LocalDeployment { approvals, queued_message_service, claude_transcript_ingest, + cli_collab, remote_client, auth_context, oauth_handoffs, @@ -375,6 +392,10 @@ impl Deployment for LocalDeployment { self.claude_transcript_ingest.as_ref() } + fn cli_collab(&self) -> &Arc { + &self.cli_collab + } + fn auth_context(&self) -> &AuthContext { &self.auth_context } diff --git a/crates/local-deployment/src/paste_transport.rs b/crates/local-deployment/src/paste_transport.rs new file mode 100644 index 00000000000..bc68c96282b --- /dev/null +++ b/crates/local-deployment/src/paste_transport.rs @@ -0,0 +1,36 @@ +use async_trait::async_trait; +use services::services::cli_collab::CliPasteTransport; +use uuid::Uuid; + +use crate::pty; + +#[derive(Debug, Clone, Default)] +pub struct LocalCliPasteTransport; + +#[async_trait] +impl CliPasteTransport for LocalCliPasteTransport { + async fn paste_and_submit(&self, workspace_id: Uuid, text: &str) -> bool { + // Close the lease-to-paste TOCTOU window with a fresh pane-subtree + // check immediately before the irreversible keystroke injection. + if pty::cli_pane_agent_running(workspace_id, "claude").await != Some(true) { + return false; + } + pty::send_cli_keys(workspace_id, text).await + } + + async fn pane_alive(&self, workspace_id: Uuid) -> bool { + pty::cli_tmux_session_exists_checked(workspace_id) + .await + .unwrap_or(false) + } + + async fn agent_running(&self, workspace_id: Uuid) -> Option { + pty::cli_pane_agent_running(workspace_id, "claude").await + } + + async fn signal_resume_ready(&self, workspace_id: Uuid, sid: &str) { + if let Err(error) = pty::write_cli_resume_ready_file(workspace_id, sid) { + tracing::warn!(?error, %workspace_id, "failed to signal CLI resume readiness"); + } + } +} diff --git a/crates/local-deployment/src/pty.rs b/crates/local-deployment/src/pty.rs index 2e939b8931a..60ad25430e1 100644 --- a/crates/local-deployment/src/pty.rs +++ b/crates/local-deployment/src/pty.rs @@ -7,7 +7,7 @@ use std::{ atomic::{AtomicBool, Ordering}, }, thread, - time::Instant, + time::{Instant, SystemTime, UNIX_EPOCH}, }; use executors::executors::cli::{CliContinue, CliLaunchSpec, CliPromptArg, CliResume}; @@ -40,6 +40,10 @@ pub enum PtyCommand { /// mode joins the *exact* conversation the chat UI is showing, and /// follow-ups from either side share one transcript. resume_session_id: Option, + /// The matching headless executor still owns the conversation. A new + /// pane shows a durable busy notice and waits for the server-owned + /// resume-ready file instead of launching `--continue` or a fresh TUI. + busy_wait_for_resume: bool, /// The workspace's initial prompt (CLI-first creation): handed to the /// interactive agent as its argument so the run happens visibly in /// the terminal instead of a headless executor. Ignored when @@ -89,6 +93,7 @@ fn cli_bootstrap( resume_session_id: Option<&str>, prompt_file: Option<&Path>, deferred_prompt_pending: bool, + busy_resume_file: Option<&Path>, ) -> String { // The program is a bare binary name from our own code; quote it anyway so // it can never be anything but a single command word. @@ -115,7 +120,31 @@ fn cli_bootstrap( CliContinue::Fresh => base.clone(), }; - let launch = if let Some(id) = active_resume_id(resume_session_id) { + let launch = if let Some(file) = busy_resume_file { + let qfile = shell_single_quote(&file.to_string_lossy()); + let resume = match &spec.resume { + CliResume::Flag(flag) => format!("{base} {flag} \"$bc_sid\""), + CliResume::Subcommand(sub) => format!("{prog} {sub} \"$bc_sid\""), + CliResume::Unsupported => base.clone(), + }; + format!( + "printf '\\nagent busy in UI — this pane will resume the conversation when it finishes\\n\\n'; \ + while :; do \ + if [ -f {qfile} ]; then \ + bc_ready=0; bc_sid=''; IFS=' ' read -r bc_ready bc_sid < {qfile} || true; \ + bc_now=$(date +%s); \ + case \"$bc_ready\" in ''|*[!0-9]*) bc_ready=0;; esac; \ + if [ \"$bc_ready\" -gt 0 ] && [ \"$bc_now\" -ge \"$bc_ready\" ] \ + && [ $((bc_now-bc_ready)) -le {RESUME_READY_TTL_SECS} ] \ + && [ -n \"$bc_sid\" ]; then \ + rm -f -- {qfile}; exec {resume}; \ + fi; \ + rm -f -- {qfile}; \ + fi; \ + sleep 1; \ + done" + ) + } else if let Some(id) = active_resume_id(resume_session_id) { match &spec.resume { // ` --resume ` — flags still apply (claude). CliResume::Flag(flag) => format!("{base} {flag} {id}"), @@ -332,6 +361,64 @@ fn cli_prompt_file_path(workspace_id: Uuid) -> PathBuf { .join(format!("{}.txt", workspace_id.simple())) } +const RESUME_READY_TTL_SECS: u64 = 60; + +/// A busy pane's resume hand-off file. It lives beside other backend-owned +/// transient assets, never in the Claude native store. +pub fn cli_resume_ready_file_path(workspace_id: Uuid) -> PathBuf { + utils::assets::asset_dir() + .join("cli-resume-ready") + .join(format!("{}.ready", workspace_id.simple())) +} + +fn write_cli_resume_ready_file_at(path: &Path, sid: &str, now: SystemTime) -> std::io::Result<()> { + let _ = Uuid::parse_str(sid) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + let timestamp = now + .duration_since(UNIX_EPOCH) + .map_err(std::io::Error::other)? + .as_secs(); + if let Some(dir) = path.parent() { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir)?; + } + #[cfg(not(unix))] + std::fs::create_dir_all(dir)?; + } + #[cfg(unix)] + { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.set_permissions(std::fs::Permissions::from_mode(0o600))?; + writeln!(file, "{timestamp} {sid}")?; + } + #[cfg(not(unix))] + std::fs::write(path, format!("{timestamp} {sid}\n"))?; + Ok(()) +} + +pub fn write_cli_resume_ready_file(workspace_id: Uuid, sid: &str) -> std::io::Result<()> { + write_cli_resume_ready_file_at( + &cli_resume_ready_file_path(workspace_id), + sid, + SystemTime::now(), + ) +} + +pub fn remove_cli_resume_ready_file(workspace_id: Uuid) { + let _ = std::fs::remove_file(cli_resume_ready_file_path(workspace_id)); +} + /// Write a workspace's CLI initial prompt to its private (0600) file for the /// bootstrap to read. Returns the path on success. The file self-deletes once /// the bootstrap consumes it; [`remove_cli_prompt_file`] and @@ -1362,6 +1449,7 @@ pub async fn resolved_cli_tmux_session_name(workspace_id: Uuid) -> String { cli_tmux_target(home, workspace_id).1 } +#[cfg(test)] async fn cli_tmux_session_exists_on( workspace_id: Uuid, current_socket: &str, @@ -1387,16 +1475,20 @@ async fn cli_tmux_session_exists_on( /// prompt and the bootstrap's `--continue` fallback takes over instead of /// replaying the prompt. `=` forces exact-name matching. pub async fn cli_tmux_session_exists(workspace_id: Uuid) -> bool { + cli_tmux_session_exists_checked(workspace_id) + .await + .unwrap_or(false) +} + +/// Fallible counterpart used by writer-lease and spawn decisions. Unlike the +/// legacy bool helper, an unreadable socket is never collapsed into absence. +pub async fn cli_tmux_session_exists_checked(workspace_id: Uuid) -> Result { if !tmux_available() { - return false; + return Ok(false); } - cli_tmux_session_exists_on( - workspace_id, - cli_tmux_socket(), - legacy_cli_tmux_socket(), - is_legacy_home_enabled(), - ) - .await + locate_cli_tmux_session(workspace_id) + .await + .map(|location| location.present) } /// Whether CLI mode can actually run claude in a tmux session (vs. degrading @@ -1439,6 +1531,11 @@ pub async fn cli_pane_agent_running(workspace_id: Uuid, program: &str) -> Option /// Check agent ownership at one already-located target without re-running the /// dual-home locator. The tmux pane probe is bounded like the locator probes. pub async fn cli_pane_agent_running_at(target: &CliTmuxTarget, program: &str) -> Option { + let (pane_pid, listing) = pane_process_listing(target).await?; + Some(pane_subtree_has_program(&listing, pane_pid, program)) +} + +async fn pane_process_listing(target: &CliTmuxTarget) -> Option<(u32, String)> { let output = run_cli_tmux(&[ "-L", &target.socket, @@ -1468,8 +1565,47 @@ pub async fn cli_pane_agent_running_at(target: &CliTmuxTarget, program: &str) -> if !snapshot.status.success() { return None; } - let listing = String::from_utf8_lossy(&snapshot.stdout); - Some(pane_subtree_has_program(&listing, pane_pid, program)) + Some(( + pane_pid, + String::from_utf8_lossy(&snapshot.stdout).into_owned(), + )) +} + +/// One matching process from the live pane subtree. The PID is exposed so the +/// authoritative writer probe can compare `/proc//cwd` without taking a +/// second, potentially different process-tree snapshot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CliPaneAgentProcess { + pub pid: u32, + pub cmdline: String, +} + +/// Fresh command lines for every matching agent process in the pane subtree. +/// `None` means tmux, the pane PID, the process snapshot, or a command line was +/// unreadable and callers must fail closed. +pub async fn cli_pane_agent_processes( + workspace_id: Uuid, + program: &str, +) -> Option> { + let target = locate_cli_tmux_target(workspace_id).await?; + let (pane_pid, listing) = pane_process_listing(&target).await?; + let mut processes = Vec::new(); + for pid in pane_subtree_program_pids(&listing, pane_pid, program) { + let output = tokio::process::Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "args="]) + .stderr(std::process::Stdio::null()) + .output() + .await + .ok()?; + if !output.status.success() { + return None; + } + processes.push(CliPaneAgentProcess { + pid, + cmdline: String::from_utf8_lossy(&output.stdout).trim().to_string(), + }); + } + Some(processes) } /// Whether `program` runs anywhere in the process subtree rooted at `root_pid` @@ -1477,6 +1613,10 @@ pub async fn cli_pane_agent_running_at(target: &CliTmuxTarget, program: &str) -> /// walk — the part that decides whether a node-wrapped agent grandchild counts /// — is unit testable without a live process tree. fn pane_subtree_has_program(ps_listing: &str, root_pid: u32, program: &str) -> bool { + !pane_subtree_program_pids(ps_listing, root_pid, program).is_empty() +} + +fn pane_subtree_program_pids(ps_listing: &str, root_pid: u32, program: &str) -> Vec { let mut comm_by_pid: HashMap = HashMap::new(); let mut children: HashMap> = HashMap::new(); for line in ps_listing.lines() { @@ -1503,6 +1643,7 @@ fn pane_subtree_has_program(ps_listing: &str, root_pid: u32, program: &str) -> b // torn read must not spin. let mut stack = vec![root_pid]; let mut visited = HashSet::new(); + let mut matches = Vec::new(); while let Some(pid) = stack.pop() { if !visited.insert(pid) { continue; @@ -1511,13 +1652,13 @@ fn pane_subtree_has_program(ps_listing: &str, root_pid: u32, program: &str) -> b .get(&pid) .is_some_and(|comm| comm_matches_program(comm, program)) { - return true; + matches.push(pid); } if let Some(kids) = children.get(&pid) { stack.extend(kids); } } - false + matches } /// Whether a `ps`/`pgrep` process name is the expected agent binary. The @@ -2459,6 +2600,7 @@ impl PtyService { tmux_resume_id, tmux_initial_prompt, tmux_deferred, + tmux_busy_wait, tmux_connect_hidden, tmux_spec, ): ( @@ -2467,11 +2609,13 @@ impl PtyService { Option, bool, bool, + bool, Option, ) = match &command { PtyCommand::TmuxCli { workspace_id, resume_session_id, + busy_wait_for_resume, initial_prompt, deferred_prompt_pending, connect_hidden, @@ -2481,10 +2625,11 @@ impl PtyService { resume_session_id.clone(), initial_prompt.clone(), *deferred_prompt_pending, + *busy_wait_for_resume, *connect_hidden, Some(spec.clone()), ), - _ => (None, None, None, false, false, None), + _ => (None, None, None, false, false, false, None), }; // Client flags are the release valve that makes smallest sizing // safe. The cached client-flags capability gates the entire @@ -2583,6 +2728,7 @@ impl PtyService { tmux_resume_id.as_deref(), prompt_file.as_deref(), tmux_deferred, + tmux_busy_wait.then(|| cli_resume_ready_file_path(workspace_id)).as_deref(), ); (conf, bootstrap) } @@ -3158,7 +3304,7 @@ mod tests { #[test] fn cli_bootstrap_runs_program_then_drops_to_shell() { - let b = cli_bootstrap(&claude_spec(&[]), None, None, false); + let b = cli_bootstrap(&claude_spec(&[]), None, None, false, None); assert!(b.contains("command -v 'claude'")); assert!( b.ends_with(r#"exec "${SHELL:-/bin/sh}""#), @@ -3239,7 +3385,7 @@ mod tests { fn cli_bootstrap_warns_with_install_hint_when_agent_missing() { // A not-installed agent must explain itself instead of silently dropping // to a bare shell. - let b = cli_bootstrap(&claude_spec(&[]), None, None, false); + let b = cli_bootstrap(&claude_spec(&[]), None, None, false, None); assert!(b.contains("if command -v 'claude'")); assert!(b.contains("is not installed or not on PATH")); assert!( @@ -3260,6 +3406,7 @@ mod tests { Some(id), Some(Path::new("/tmp/vk/prompt.txt")), false, + None, ); assert!(b.contains(&format!("--resume {id}"))); // The prompt file is ignored entirely when resuming. @@ -3267,7 +3414,7 @@ mod tests { assert!(!b.contains("bc_p=")); // Non-UUID (injection attempt) is rejected and never interpolated. let evil = "x; rm -rf ~"; - let b = cli_bootstrap(&claude_spec(&[]), Some(evil), None, false); + let b = cli_bootstrap(&claude_spec(&[]), Some(evil), None, false, None); assert!(!b.contains("rm -rf")); assert!(!b.contains("--resume")); } @@ -3278,14 +3425,14 @@ mod tests { // settings, so the model/sandbox/approval flags are NOT replayed. let id = "28b98f08-5f5f-4b1e-8c4e-41ae87c0c706"; let spec = codex_spec(&["-m", "gpt-5.5", "-s", "danger-full-access"]); - let b = cli_bootstrap(&spec, Some(id), None, false); + let b = cli_bootstrap(&spec, Some(id), None, false, None); assert!(b.contains(&format!("'codex' resume {id}"))); assert!( !b.contains("-m"), "base flags must not ride the resume: {b}" ); // Continue fallback uses `resume --last`, falling back to a fresh TUI. - let cont = cli_bootstrap(&spec, None, None, false); + let cont = cli_bootstrap(&spec, None, None, false, None); assert!(cont.contains("'codex' resume --last || 'codex'")); } @@ -3298,7 +3445,7 @@ mod tests { // word-split or parsed as shell (injection-safe by construction). let spec = claude_spec(&["--dangerously-skip-permissions"]); let file = Path::new("/tmp/vk/cli-prompts/abc.txt"); - let b = cli_bootstrap(&spec, None, Some(file), false); + let b = cli_bootstrap(&spec, None, Some(file), false, None); assert!( b.len() < 2048, "bootstrap must stay small regardless of prompt size: {} bytes", @@ -3317,7 +3464,7 @@ mod tests { // embedded quote is POSIX-escaped as `'\''`, so the dangerous run stays // inert data inside the quoting rather than terminating it. let evil = Path::new("/tmp/'; rm -rf ~; echo '.txt"); - let b = cli_bootstrap(&spec, None, Some(evil), false); + let b = cli_bootstrap(&spec, None, Some(evil), false, None); assert!( b.contains(r"'\''; rm -rf ~; echo '\''"), "path quote must be escaped, not terminated: {b}" @@ -3338,7 +3485,7 @@ mod tests { // the flag itself is quoted like every other word we emit. let flag_spec = CliLaunchSpec::new("gemini", vec![]) .with_prompt_arg(CliPromptArg::Flag("-i".to_string())); - let b = cli_bootstrap(&flag_spec, None, Some(file), false); + let b = cli_bootstrap(&flag_spec, None, Some(file), false, None); assert!(b.contains("rm -f -- '/tmp/vk/p.txt'; 'gemini' '-i' \"$bc_p\"")); // StdinPipe agents pipe the file into the program — no argv ceiling. @@ -3346,7 +3493,7 @@ mod tests { // the file, so consumption is acknowledged (file gone) immediately — // not when the agent eventually exits. let pipe_spec = CliLaunchSpec::new("amp", vec![]).with_prompt_arg(CliPromptArg::StdinPipe); - let b = cli_bootstrap(&pipe_spec, None, Some(file), false); + let b = cli_bootstrap(&pipe_spec, None, Some(file), false, None); assert!(b.contains("{ cat '/tmp/vk/p.txt'; rm -f -- '/tmp/vk/p.txt'; } | 'amp'")); } @@ -3355,7 +3502,7 @@ mod tests { // No prompt file (blank prompt filtered out by the caller) -> the // no-prompt continue/fresh path, exactly as before. let spec = claude_spec(&["--dangerously-skip-permissions"]); - let b = cli_bootstrap(&spec, None, None, false); + let b = cli_bootstrap(&spec, None, None, false, None); assert!(b.contains("--continue || 'claude'")); assert!(!b.contains("bc_p=")); } @@ -3366,15 +3513,66 @@ mod tests { // first leg on a brand-new workspace would live just long enough to // swallow the paste and exit), just the bare agent TUI. let spec = claude_spec(&["--dangerously-skip-permissions"]); - let b = cli_bootstrap(&spec, None, None, true); + let b = cli_bootstrap(&spec, None, None, true, None); assert!(!b.contains("--continue"), "no doomed continue leg: {b}"); assert!(b.contains("'claude' '--dangerously-skip-permissions'")); // Resume still wins over a pending deferred paste at launch time. let id = "28b98f08-5f5f-4b1e-8c4e-41ae87c0c706"; - let b = cli_bootstrap(&spec, Some(id), None, true); + let b = cli_bootstrap(&spec, Some(id), None, true, None); assert!(b.contains(&format!("--resume {id}"))); } + #[cfg(unix)] + #[test] + fn busy_notice_resume_ready_file_lifecycle() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().unwrap(); + let bin = temp.path().join("bin"); + std::fs::create_dir(&bin).unwrap(); + let claude = bin.join("claude"); + std::fs::write( + &claude, + "#!/bin/sh\nprintf '%s\\n' \"$*\" > \"$BC_TEST_OUTPUT\"\n", + ) + .unwrap(); + std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o700)).unwrap(); + + let ready = temp.path().join("resume.ready"); + let sid = "28b98f08-5f5f-4b1e-8c4e-41ae87c0c706"; + write_cli_resume_ready_file_at(&ready, sid, SystemTime::now()).unwrap(); + assert_eq!( + std::fs::metadata(&ready).unwrap().permissions().mode() & 0o777, + 0o600 + ); + let bootstrap = cli_bootstrap(&claude_spec(&[]), None, None, false, Some(&ready)); + assert!(bootstrap.contains( + "agent busy in UI — this pane will resume the conversation when it finishes" + )); + assert!(!bootstrap.contains("--continue")); + + let output = temp.path().join("args.txt"); + let path = format!( + "{}:{}", + bin.display(), + std::env::var("PATH").unwrap_or_default() + ); + let status = std::process::Command::new("/bin/sh") + .arg("-c") + .arg(bootstrap) + .env("PATH", path) + .env("BC_TEST_OUTPUT", &output) + .env("SHELL", "/bin/true") + .status() + .unwrap(); + assert!(status.success()); + assert_eq!( + std::fs::read_to_string(output).unwrap().trim(), + format!("--resume {sid}") + ); + assert!(!ready.exists(), "ready file must be consumed before exec"); + } + #[test] fn route_followup_prompt_always_pastes_or_drops() { // Follow-up delivery (live session / post-resume) is never baked. @@ -3658,6 +3856,7 @@ mod tests { None, None, false, + None, ); assert!(b.contains( "'claude' '--dangerously-skip-permissions' --continue || 'claude' '--dangerously-skip-permissions'" @@ -3667,7 +3866,13 @@ mod tests { #[test] fn cli_bootstrap_shell_quotes_agent_args_on_every_form() { // Glob/metacharacters in a model id stay inert (single-quoted)... - let b = cli_bootstrap(&claude_spec(&["--model", "opus[1m]"]), None, None, false); + let b = cli_bootstrap( + &claude_spec(&["--model", "opus[1m]"]), + None, + None, + false, + None, + ); assert!(b.contains("'--model' 'opus[1m]'")); // ...and the flags ride the continue/fresh fallback too. assert!(b.contains("'opus[1m]' --continue")); @@ -4001,6 +4206,7 @@ mod tests { let command = PtyCommand::TmuxCli { workspace_id, resume_session_id: None, + busy_wait_for_resume: false, initial_prompt: None, deferred_prompt_pending: false, connect_hidden: false, diff --git a/crates/local-deployment/src/writer_probe.rs b/crates/local-deployment/src/writer_probe.rs new file mode 100644 index 00000000000..a7095a52225 --- /dev/null +++ b/crates/local-deployment/src/writer_probe.rs @@ -0,0 +1,211 @@ +use std::{collections::HashSet, path::Path}; + +use async_trait::async_trait; +use db::{DBService, models::cli_pane_binding::CliPaneBinding}; +use services::services::cli_collab::{CliWriterProbe, ProbeReport, SidEvidence}; +use uuid::Uuid; + +use crate::pty::{cli_pane_agent_processes, cli_tmux_session_exists_checked}; + +#[derive(Clone)] +pub struct LocalCliWriterProbe { + db: DBService, +} + +impl LocalCliWriterProbe { + pub fn new(db: DBService) -> Self { + Self { db } + } +} + +fn resume_evidence(cmdlines: &[String]) -> SidEvidence { + let mut ids = HashSet::new(); + let mut saw_resume = false; + let mut invalid = false; + for cmdline in cmdlines { + let words: Vec<_> = cmdline.split_whitespace().collect(); + for (index, word) in words.iter().enumerate() { + let candidate = if *word == "--resume" { + saw_resume = true; + words.get(index + 1).copied() + } else if let Some(value) = word.strip_prefix("--resume=") { + saw_resume = true; + Some(value) + } else { + None + }; + if let Some(candidate) = candidate { + match Uuid::parse_str(candidate) { + Ok(id) => { + ids.insert(id.to_string()); + } + Err(_) => invalid = true, + } + } else if *word == "--resume" { + invalid = true; + } + } + } + if invalid || ids.len() > 1 { + SidEvidence::Ambiguous + } else if let Some(id) = ids.into_iter().next() { + SidEvidence::ConfirmedResume(id) + } else if saw_resume { + SidEvidence::Ambiguous + } else { + SidEvidence::NoResumeArg + } +} + +#[cfg(target_os = "linux")] +fn only_active_claude_in_cwd(effective_dir: &Path, pane_pids: &HashSet) -> Option { + let effective_dir = effective_dir + .canonicalize() + .unwrap_or_else(|_| effective_dir.to_path_buf()); + let entries = std::fs::read_dir("/proc").ok()?; + let mut native = Vec::new(); + let mut wrappers = Vec::new(); + for entry in entries.flatten() { + let Some(pid) = entry + .file_name() + .to_str() + .and_then(|name| name.parse::().ok()) + else { + continue; + }; + let stat = match std::fs::read_to_string(entry.path().join("stat")) { + Ok(stat) => stat, + Err(_) => continue, + }; + let Some(open) = stat.find('(') else { + continue; + }; + let Some(close) = stat.rfind(')') else { + continue; + }; + let comm = &stat[open + 1..close]; + let is_wrapper = comm == "node" + && std::fs::read(entry.path().join("cmdline")) + .map(|bytes| bytes.windows(b"claude".len()).any(|part| part == b"claude")) + .unwrap_or(false); + if comm != "claude" && !is_wrapper { + continue; + } + let Ok(cwd) = std::fs::read_link(entry.path().join("cwd")) else { + continue; + }; + if cwd != effective_dir { + continue; + } + if comm == "claude" { + native.push(pid); + } else { + wrappers.push(pid); + } + } + let candidates = if native.is_empty() { wrappers } else { native }; + Some(candidates.len() == 1 && pane_pids.contains(&candidates[0])) +} + +#[cfg(not(target_os = "linux"))] +fn only_active_claude_in_cwd(_effective_dir: &Path, _pane_pids: &HashSet) -> Option { + None +} + +#[async_trait] +impl CliWriterProbe for LocalCliWriterProbe { + async fn probe( + &self, + workspace_id: Uuid, + effective_dir: &Path, + expected_sid: Option<&str>, + ) -> ProbeReport { + let binding = + match CliPaneBinding::find_active_for_workspace(&self.db.pool, workspace_id).await { + Ok(binding) => binding, + Err(error) => { + tracing::warn!(?error, %workspace_id, "CLI pane binding probe failed"); + return ProbeReport::failed(); + } + }; + let exists = match cli_tmux_session_exists_checked(workspace_id).await { + Ok(exists) => exists, + Err(error) => { + tracing::warn!(?error, %workspace_id, "CLI tmux writer probe failed"); + return ProbeReport::failed(); + } + }; + if !exists { + if let Some(binding) = binding + && let Err(error) = CliPaneBinding::release(&self.db.pool, binding.id).await + { + tracing::warn!(?error, %workspace_id, "dead CLI pane binding release failed"); + return ProbeReport::failed(); + } + return ProbeReport { + pane_session_exists: false, + agent_running: Some(false), + sid_evidence: SidEvidence::Unknown, + probe_failed: false, + only_active_claude_in_cwd: Some(false), + }; + } + + let processes = match cli_pane_agent_processes(workspace_id, "claude").await { + Some(processes) => processes, + None => return ProbeReport::failed(), + }; + let agent_running = !processes.is_empty(); + let cmdlines: Vec<_> = processes + .iter() + .map(|process| process.cmdline.clone()) + .collect(); + let mut sid_evidence = if agent_running { + resume_evidence(&cmdlines) + } else { + SidEvidence::Unknown + }; + if agent_running + && let (Some(expected), Some(binding)) = (expected_sid, binding.as_ref()) + && binding.claude_session_id.as_deref() == Some(expected) + { + sid_evidence = SidEvidence::ConfirmedResume(expected.to_string()); + } + let pane_pids = processes.iter().map(|process| process.pid).collect(); + ProbeReport { + pane_session_exists: true, + agent_running: Some(agent_running), + sid_evidence, + probe_failed: false, + only_active_claude_in_cwd: if agent_running { + only_active_claude_in_cwd(effective_dir, &pane_pids) + } else { + Some(false) + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resume_cmdline_evidence_is_exact_and_ambiguous_on_disagreement() { + assert_eq!( + resume_evidence(&["claude --resume 11111111-1111-4111-8111-111111111111".into()]), + SidEvidence::ConfirmedResume("11111111-1111-4111-8111-111111111111".into()) + ); + assert_eq!( + resume_evidence(&[ + "claude --resume 11111111-1111-4111-8111-111111111111".into(), + "claude --resume 22222222-2222-4222-8222-222222222222".into(), + ]), + SidEvidence::Ambiguous + ); + assert_eq!( + resume_evidence(&["claude --model opus".into()]), + SidEvidence::NoResumeArg + ); + } +} diff --git a/crates/server/src/bin/generate_types.rs b/crates/server/src/bin/generate_types.rs index 25a87ffd56f..b6dbc46cf27 100644 --- a/crates/server/src/bin/generate_types.rs +++ b/crates/server/src/bin/generate_types.rs @@ -46,6 +46,13 @@ fn generate_types_content() -> String { db::models::workspace::Workspace::decl(), db::models::workspace::WorkspaceWithStatus::decl(), db::models::session::Session::decl(), + db::models::session_queued_message::QueuedMessageSource::decl(), + db::models::session_queued_message::QueuedMessageState::decl(), + db::models::session_queued_message::SessionQueuedMessage::decl(), + db::models::cli_pane_binding::CliPaneBoundVia::decl(), + db::models::cli_pane_binding::CliPaneBinding::decl(), + db::models::workspace_spawn_reservation::SpawnReservationHolder::decl(), + db::models::workspace_spawn_reservation::WorkspaceSpawnReservation::decl(), db::models::execution_process::ExecutionProcess::decl(), db::models::execution_process::ExecutionProcessStatus::decl(), db::models::execution_process::ExecutionProcessRunReason::decl(), @@ -131,6 +138,8 @@ fn generate_types_content() -> String { relay_types::RefreshRelaySigningSessionRequest::decl(), relay_types::RefreshRelaySigningSessionResponse::decl(), server::routes::sessions::CreateFollowUpAttempt::decl(), + server::routes::sessions::ForkRecoveryRequest::decl(), + server::routes::sessions::queue::QueueMessageRequest::decl(), server::routes::sessions::ResetProcessRequest::decl(), server::routes::workspaces::git::ChangeTargetBranchRequest::decl(), server::routes::workspaces::git::ChangeTargetBranchResponse::decl(), @@ -212,6 +221,7 @@ fn generate_types_content() -> String { git::GitBranch::decl(), services::services::queued_message::QueuedMessage::decl(), services::services::queued_message::QueueStatus::decl(), + services::services::cli_collab::DispatchOutcome::decl(), git::ConflictOp::decl(), executors::actions::ExecutorAction::decl(), executors::mcp_config::McpConfig::decl(), diff --git a/crates/server/src/error.rs b/crates/server/src/error.rs index 202bbae581c..77d8d72fa13 100644 --- a/crates/server/src/error.rs +++ b/crates/server/src/error.rs @@ -19,6 +19,7 @@ use relay_hosts::{ use relay_webrtc::WebRtcError; use services::services::{ claude_transcript_ingest::ClaudeTranscriptIngestError, + cli_collab::CliCollabError, config::{ConfigError, EditorOpenError}, container::ContainerError, file::FileError, @@ -65,6 +66,8 @@ pub enum ApiError { File(#[from] FileError), #[error(transparent)] QueuedMessage(#[from] QueuedMessageError), + #[error(transparent)] + CliCollab(#[from] CliCollabError), #[error("Multipart error: {0}")] Multipart(#[from] MultipartError), #[error("IO error: {0}")] @@ -464,6 +467,7 @@ impl IntoResponse for ApiError { message: Some("Failed to process file. Please try again.".into()), }, ApiError::QueuedMessage(_) => ErrorInfo::internal("QueuedMessageError"), + ApiError::CliCollab(_) => ErrorInfo::internal("CliCollabError"), ApiError::EditorOpen(EditorOpenError::LaunchFailed { .. }) => { ErrorInfo::internal("EditorLaunchError") diff --git a/crates/server/src/routes/sessions/mod.rs b/crates/server/src/routes/sessions/mod.rs index fbd3558d2bb..abc30009f97 100644 --- a/crates/server/src/routes/sessions/mod.rs +++ b/crates/server/src/routes/sessions/mod.rs @@ -1,31 +1,34 @@ pub mod queue; pub mod review; +use std::str::FromStr; + use axum::{ Extension, Json, Router, extract::{Query, State}, + http::StatusCode, middleware::from_fn_with_state, response::Json as ResponseJson, routing::{get, post}, }; use db::models::{ - coding_agent_turn::CodingAgentTurn, + cli_native_record::CliNativeRecord, execution_process::{ExecutionProcess, ExecutionProcessRunReason}, requests::UpdateSession, scratch::{Scratch, ScratchType}, session::{CreateSession, Session, SessionError}, + session_queued_message::QueuedMessageSource, workspace::{Workspace, WorkspaceError}, workspace_repo::WorkspaceRepo, }; use deployment::Deployment; use executors::{ - actions::{ - ExecutorAction, ExecutorActionType, coding_agent_follow_up::CodingAgentFollowUpRequest, - }, + actions::ExecutorActionType, + executors::{BaseCodingAgent, claude::native::adapt_native_claude_line}, profile::ExecutorConfig, }; use serde::Deserialize; -use services::services::container::ContainerService; +use services::services::{cli_collab::DispatchOutcome, container::ContainerService}; use ts_rs::TS; use utils::response::ApiResponse; use uuid::Uuid; @@ -112,6 +115,8 @@ pub struct CreateFollowUpAttempt { pub retry_process_id: Option, pub force_when_dirty: Option, pub perform_git_reset: Option, + #[serde(default)] + pub replace: bool, } #[derive(Debug, Deserialize, TS)] @@ -121,11 +126,27 @@ pub struct ResetProcessRequest { pub perform_git_reset: Option, } +type DispatchResponse = ( + StatusCode, + ResponseJson>, +); + +fn dispatch_response(outcome: DispatchOutcome) -> DispatchResponse { + if matches!(&outcome, DispatchOutcome::Conflict { .. }) { + ( + StatusCode::CONFLICT, + ResponseJson(ApiResponse::error_with_data(outcome)), + ) + } else { + (StatusCode::OK, ResponseJson(ApiResponse::success(outcome))) + } +} + pub async fn follow_up( Extension(session): Extension, State(deployment): State, Json(payload): Json, -) -> Result>, ApiError> { +) -> Result { let pool = &deployment.db().pool; // Load workspace from session @@ -175,53 +196,32 @@ pub async fn follow_up( .await?; } - let latest_session_info = CodingAgentTurn::find_latest_session_info(pool, session.id).await?; - - let prompt = payload.prompt; - - let repos = WorkspaceRepo::find_repos_for_workspace(pool, workspace.id).await?; - let cleanup_action = deployment.container().cleanup_actions_for_repos(&repos); - - let working_dir = session - .agent_working_dir - .as_ref() - .filter(|dir| !dir.is_empty()) - .cloned(); - - let action_type = if let Some(info) = latest_session_info { - let is_reset = payload.retry_process_id.is_some(); - ExecutorActionType::CodingAgentFollowUpRequest(CodingAgentFollowUpRequest { - prompt: prompt.clone(), - session_id: info.session_id, - reset_to_message_id: if is_reset { info.message_id } else { None }, - executor_config: payload.executor_config.clone(), - working_dir: working_dir.clone(), - }) - } else { - ExecutorActionType::CodingAgentInitialRequest( - executors::actions::coding_agent_initial::CodingAgentInitialRequest { - prompt, - executor_config: payload.executor_config.clone(), - working_dir, - }, - ) - }; - - let action = ExecutorAction::new(action_type, cleanup_action.map(Box::new)); - - let execution_process = deployment - .container() - .start_execution( - &workspace, + let outcome = match deployment + .cli_collab() + .dispatch_gate( &session, - &action, - &ExecutionProcessRunReason::CodingAgent, + payload.prompt, + payload.executor_config, + QueuedMessageSource::Ui, + payload.replace, ) - .await?; + .await + { + Ok(outcome) => outcome, + Err(error) => { + tracing::warn!(?error, session_id = %session.id, "follow-up dispatch gate failed closed"); + return Err(ApiError::Conflict( + "Writer state could not be verified; the message was not dispatched".to_string(), + )); + } + }; + let conflict = matches!(&outcome, DispatchOutcome::Conflict { .. }); // Clear the draft follow-up scratch on successful spawn // This ensures the scratch is wiped even if the user navigates away quickly - if let Err(e) = Scratch::delete(pool, session.id, &ScratchType::DraftFollowUp).await { + if !conflict + && let Err(e) = Scratch::delete(pool, session.id, &ScratchType::DraftFollowUp).await + { // Log but don't fail the request - scratch deletion is best-effort tracing::debug!( "Failed to delete draft follow-up scratch for session {}: {}", @@ -230,7 +230,117 @@ pub async fn follow_up( ); } - Ok(ResponseJson(ApiResponse::success(execution_process))) + Ok(dispatch_response(outcome)) +} + +#[derive(Debug, Deserialize, TS)] +pub struct ForkRecoveryRequest { + pub fork_parent_uuid: String, + pub branch_leaf_uuid: String, +} + +async fn latest_executor_config( + pool: &sqlx::SqlitePool, + session: &Session, +) -> Result { + for process in ExecutionProcess::find_by_session_id(pool, session.id, false) + .await? + .iter() + .rev() + { + let Ok(action) = process.executor_action() else { + continue; + }; + match action.typ() { + ExecutorActionType::CodingAgentInitialRequest(request) => { + return Ok(request.executor_config.clone()); + } + ExecutorActionType::CodingAgentFollowUpRequest(request) => { + return Ok(request.executor_config.clone()); + } + _ => {} + } + } + let executor = session + .executor + .as_deref() + .and_then(|executor| BaseCodingAgent::from_str(executor).ok()) + .ok_or_else(|| { + ApiError::BadRequest("No executor configuration exists for this session".to_string()) + })?; + Ok(ExecutorConfig::new(executor)) +} + +pub async fn fork_recovery( + Extension(session): Extension, + State(deployment): State, + Json(payload): Json, +) -> Result { + let pool = &deployment.db().pool; + let workspace = Workspace::find_by_id(pool, session.workspace_id) + .await? + .ok_or(ApiError::Workspace(WorkspaceError::ValidationError( + "Workspace not found".to_string(), + )))?; + deployment + .container() + .ensure_container_exists(&workspace) + .await?; + let path = CliNativeRecord::dropped_branch_path( + pool, + session.id, + &payload.fork_parent_uuid, + &payload.branch_leaf_uuid, + ) + .await? + .ok_or_else(|| ApiError::BadRequest("The requested fork branch was not found".to_string()))?; + let mut prompts = Vec::new(); + for record in path { + if record.uuid.as_deref() == Some(payload.fork_parent_uuid.as_str()) + || record.kind != "user" + { + continue; + } + if let Ok(line) = adapt_native_claude_line(&record.raw, &record.claude_session_id) + && let Some(prompt) = line.plain_user_text() + { + prompts.push(prompt); + } + } + if prompts.is_empty() { + return Err(ApiError::BadRequest( + "The requested fork branch has no recoverable user messages".to_string(), + )); + } + let prompt = if prompts.len() == 1 { + prompts.pop().expect("one prompt exists") + } else { + format!( + "Recovered messages from an alternate CLI branch:\n\n{}", + prompts.join("\n\n--- recovered message ---\n\n") + ) + }; + let executor_config = latest_executor_config(pool, &session).await?; + let outcome = match deployment + .cli_collab() + .dispatch_gate( + &session, + prompt, + executor_config, + QueuedMessageSource::Recovery, + false, + ) + .await + { + Ok(outcome) => outcome, + Err(error) => { + tracing::warn!(?error, session_id = %session.id, "fork recovery dispatch failed closed"); + return Err(ApiError::Conflict( + "Writer state could not be verified; recovery was not dispatched".to_string(), + )); + } + }; + Ok(dispatch_response(outcome)) } pub async fn reset_process( @@ -315,6 +425,7 @@ pub fn router(deployment: &DeploymentImpl) -> Router { let session_id_router = Router::new() .route("/", get(get_session).put(update_session)) .route("/follow-up", post(follow_up)) + .route("/fork-recovery", post(fork_recovery)) .route("/reset", post(reset_process)) .route("/setup", post(run_setup_script)) .route("/review", post(review::start_review)) diff --git a/crates/server/src/routes/terminal.rs b/crates/server/src/routes/terminal.rs index c0214c9f2f8..8ef984a77d4 100644 --- a/crates/server/src/routes/terminal.rs +++ b/crates/server/src/routes/terminal.rs @@ -11,8 +11,14 @@ use axum::{ }; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use db::models::{ - coding_agent_turn::CodingAgentTurn, execution_process::ExecutionProcess, session::Session, - workspace::Workspace, workspace_repo::WorkspaceRepo, + claude_session_link::{ClaudeSessionBoundVia, ClaudeSessionLink}, + cli_pane_binding::{CliPaneBinding, CliPaneBoundVia}, + coding_agent_turn::CodingAgentTurn, + execution_process::ExecutionProcess, + session::Session, + workspace::Workspace, + workspace_repo::WorkspaceRepo, + workspace_spawn_reservation::{SpawnReservationHolder, WorkspaceSpawnReservation}, }; use deployment::Deployment; use executors::{ @@ -22,8 +28,9 @@ use executors::{ }; use local_deployment::pty::{ CLI_PROMPT_PARKED_NOTICE, CliPromptDelivery, CliPromptRouting, PtyCommand, - cli_pane_agent_running_at, cli_prompt_file_exists, cli_tmux_available, cli_tmux_session_exists, - cli_tmux_target_exists, locate_cli_tmux_target, remove_cli_prompt_file, + cli_pane_agent_running_at, cli_prompt_file_exists, cli_tmux_available, + cli_tmux_session_exists_checked, cli_tmux_target_exists, kill_cli_tmux_session, + locate_cli_tmux_target, remove_cli_prompt_file, remove_cli_resume_ready_file, resolved_cli_tmux_session_name, route_followup_prompt, route_initial_prompt, send_cli_keys_to, }; use serde::{Deserialize, Serialize}; @@ -193,6 +200,7 @@ async fn terminal_ws( // Set inside the Cli arm when this attach carries the workspace's parked // prompt; consumed by handle_terminal_ws to confirm delivery and clear it. let mut prompt_delivery: Option = None; + let mut cli_launch_registration: Option = None; let (working_dir, command) = match query.mode { TerminalMode::Cli => { @@ -224,17 +232,12 @@ async fn terminal_ws( // latest session. let mut session = match query.session_id { Some(session_id) => Session::find_by_id(pool, session_id) - .await - .ok() - .flatten() + .await? .filter(|s| s.workspace_id == query.workspace_id), None => None, }; if session.is_none() { - session = Session::find_latest_by_workspace_id(pool, query.workspace_id) - .await - .ok() - .flatten(); + session = Session::find_latest_by_workspace_id(pool, query.workspace_id).await?; } // Run claude exactly where the coding agent runs: the workspace @@ -260,24 +263,70 @@ async fn terminal_ws( // executor is actively RUNNING this session, never hand its id to // a second claude — resuming a session mid-write forks it and the // user ends up with chat and CLI doing the same work twice. - let resume_session_id = match &session { + let (executor_active, known_claude_session_id) = match &session { Some(s) => { let executor_active = - ExecutionProcess::has_running_coding_agent_for_session(pool, s.id) - .await - .unwrap_or(false); - if executor_active { - None - } else { - CodingAgentTurn::find_latest_session_info(pool, s.id) + match ExecutionProcess::has_running_coding_agent_for_session(pool, s.id) .await - .ok() - .flatten() - .map(|info| info.session_id) - } + { + Ok(active) => active, + Err(error) => { + tracing::warn!(?error, session_id = %s.id, "terminal writer guard failed closed"); + true + } + }; + let sid = CodingAgentTurn::find_latest_session_info(pool, s.id) + .await? + .map(|info| info.session_id) + .or(ClaudeSessionLink::find_latest_for_session(pool, s.id) + .await? + .map(|link| link.claude_session_id)); + (executor_active, sid) } - None => None, + None => (false, None), + }; + let resume_session_id = (!executor_active) + .then(|| known_claude_session_id.clone()) + .flatten(); + let cli_session_exists = if cli_tmux_available() { + cli_tmux_session_exists_checked(query.workspace_id).await? + } else { + false }; + if cli_tmux_available() && !cli_session_exists { + let launch_session = session.as_ref().ok_or_else(|| { + ApiError::BadRequest( + "CLI mode requires a BetterCoding session for this workspace".to_string(), + ) + })?; + let reservation = WorkspaceSpawnReservation::acquire( + pool, + query.workspace_id, + SpawnReservationHolder::Cli, + ) + .await? + .ok_or_else(|| { + ApiError::Conflict( + "Another writer is starting for this workspace; retry the terminal attach" + .to_string(), + ) + })?; + if executor_active { + remove_cli_resume_ready_file(query.workspace_id); + } + cli_launch_registration = Some(CliLaunchRegistration { + workspace_id: query.workspace_id, + session_id: launch_session.id, + claude_session_id: known_claude_session_id.clone(), + bound_via: if known_claude_session_id.is_some() { + CliPaneBoundVia::CliResume + } else { + CliPaneBoundVia::CliFresh + }, + cwd: dir.to_string_lossy().into_owned(), + fence: reservation.fence, + }); + } // A parked prompt (CLI-first creation, or a re-parked loop wake-up) // is only ever PEEKED here (read, don't clear); the clear happens @@ -367,8 +416,7 @@ async fn terminal_ws( let resume_will_apply = resume_session_id .as_deref() .is_some_and(|id| Uuid::parse_str(id).is_ok()); - let fresh_launch = - !resume_will_apply && !cli_tmux_session_exists(query.workspace_id).await; + let fresh_launch = !resume_will_apply && !cli_session_exists; let routed = if fresh_launch { route_initial_prompt(Some(peeked.clone()), &spec.prompt_arg) } else { @@ -429,6 +477,7 @@ async fn terminal_ws( PtyCommand::TmuxCli { workspace_id: query.workspace_id, resume_session_id, + busy_wait_for_resume: executor_active, initial_prompt: baked_prompt, deferred_prompt_pending, connect_hidden: query.hidden, @@ -471,6 +520,7 @@ async fn terminal_ws( query.rows.unwrap_or(DEFAULT_ROWS), command, prompt_delivery, + cli_launch_registration, ) })) } @@ -500,6 +550,15 @@ struct PromptDelivery { claim: CliPromptDelivery, } +struct CliLaunchRegistration { + workspace_id: Uuid, + session_id: Uuid, + claude_session_id: Option, + bound_via: CliPaneBoundVia, + cwd: String, + fence: String, +} + async fn handle_terminal_ws( mut socket: MaybeSignedWebSocket, deployment: DeploymentImpl, @@ -508,6 +567,7 @@ async fn handle_terminal_ws( rows: u16, command: PtyCommand, prompt_delivery: Option, + cli_launch_registration: Option, ) { // FIX 4 tripwire label: the pty session name, captured before `command` is // moved into `create_session`. For CLI mode this is the actual current @@ -535,11 +595,44 @@ async fn handle_terminal_ws( if let Some(delivery) = &prompt_delivery { remove_cli_prompt_file(delivery.workspace_id); } + if let Some(registration) = &cli_launch_registration { + let _ = WorkspaceSpawnReservation::release( + &deployment.db().pool, + registration.workspace_id, + ®istration.fence, + ) + .await; + } let _ = send_error(&mut socket, &e.to_string()).await; return; } }; + if let Some(registration) = cli_launch_registration { + let registered = match wait_for_cli_session(registration.workspace_id).await { + Ok(true) => register_cli_launch(&deployment, ®istration).await, + Ok(false) => Err(ApiError::Conflict( + "CLI tmux session did not become ready".to_string(), + )), + Err(error) => Err(ApiError::Pty(error)), + }; + let _ = WorkspaceSpawnReservation::release( + &deployment.db().pool, + registration.workspace_id, + ®istration.fence, + ) + .await; + if let Err(error) = registered { + kill_cli_tmux_session(registration.workspace_id).await; + if let Some(delivery) = &prompt_delivery { + remove_cli_prompt_file(delivery.workspace_id); + } + let _ = send_error(&mut socket, &error.to_string()).await; + let _ = deployment.pty().close_session(session_id).await; + return; + } + } + // FIX 4 input tripwire — bounds and rationale live on `AttachInputTripwire`. let mut tripwire = AttachInputTripwire::new(tripwire_session, session_id); @@ -551,7 +644,7 @@ async fn handle_terminal_ws( // the user so, instead of silently destroying it. if let Some(delivery) = prompt_delivery { let workspace_id = delivery.workspace_id; - if wait_for_cli_session(workspace_id).await { + if matches!(wait_for_cli_session(workspace_id).await, Ok(true)) { // Confirm delivery and clear the parked prompt in the background so // the pane's output starts streaming immediately — the confirmation // polls below would otherwise hold the whole terminal blank. @@ -687,16 +780,45 @@ async fn handle_terminal_ws( /// only spent when the launch is failing — and a window that's too short would /// false-negative on a heavily loaded machine, stranding a prompt that WAS /// delivered (the replay hazard the deferred clear exists to prevent). -async fn wait_for_cli_session(workspace_id: Uuid) -> bool { +async fn register_cli_launch( + deployment: &DeploymentImpl, + registration: &CliLaunchRegistration, +) -> Result<(), ApiError> { + let binding = CliPaneBinding::record_launch( + &deployment.db().pool, + registration.workspace_id, + registration.session_id, + registration.claude_session_id.as_deref(), + registration.bound_via, + ) + .await?; + if let Some(sid) = registration.claude_session_id.as_deref() + && let Err(error) = ClaudeSessionLink::assign_cli( + &deployment.db().pool, + sid, + registration.session_id, + registration.workspace_id, + ®istration.cwd, + ClaudeSessionBoundVia::CliResume, + ) + .await + { + let _ = CliPaneBinding::release(&deployment.db().pool, binding.id).await; + return Err(error.into()); + } + Ok(()) +} + +async fn wait_for_cli_session(workspace_id: Uuid) -> Result { for attempt in 0..10 { - if cli_tmux_session_exists(workspace_id).await { - return true; + if cli_tmux_session_exists_checked(workspace_id).await? { + return Ok(true); } if attempt < 9 { tokio::time::sleep(std::time::Duration::from_millis(250)).await; } } - false + Ok(false) } /// Confirm a baked prompt was actually handed to the agent: the staged file is diff --git a/crates/services/src/services/claude_transcript_ingest.rs b/crates/services/src/services/claude_transcript_ingest.rs index b2c27f8e066..f49a2bfb453 100644 --- a/crates/services/src/services/claude_transcript_ingest.rs +++ b/crates/services/src/services/claude_transcript_ingest.rs @@ -30,8 +30,10 @@ use db::{ cli_ingest_outbox::CliIngestOutbox, cli_native_file::{CliNativeFile, RegisterCliNativeFile}, cli_native_record::{ - CliNativeRecord, CliNativeRecordDisposition, ImportedCursor, NewCliNativeRecord, + CliNativeRecord, CliNativeRecordDisposition, ImportedCursor, NativeImportContext, + NewCliNativeRecord, }, + cli_pane_binding::{CliPaneBinding, CliPaneBoundVia}, session::Session, workspace::{Workspace, WorkspaceError}, }, @@ -61,7 +63,10 @@ use self::{ ObservedFileState, StoredTailState, hash_bytes, read_complete_line_batch, rescan_reason, }, }; -use crate::services::filesystem_watcher; +use crate::services::{ + cli_collab::{CliWriterProbe, SidEvidence}, + filesystem_watcher, +}; const REGISTRY_RECONCILE_INTERVAL: Duration = Duration::from_secs(30); const OUTBOX_SAFETY_POLL_INTERVAL: Duration = Duration::from_secs(30); @@ -134,9 +139,32 @@ struct ImportPathState { force_rescan: bool, } +#[cfg(test)] +struct TestNoPaneWriterProbe; + +#[cfg(test)] +#[async_trait::async_trait] +impl CliWriterProbe for TestNoPaneWriterProbe { + async fn probe( + &self, + _workspace_id: Uuid, + _effective_dir: &Path, + _expected_sid: Option<&str>, + ) -> crate::services::cli_collab::ProbeReport { + crate::services::cli_collab::ProbeReport { + pane_session_exists: false, + agent_running: Some(false), + sid_evidence: SidEvidence::Unknown, + probe_failed: false, + only_active_claude_in_cwd: Some(false), + } + } +} + pub struct ClaudeTranscriptIngest { db: DBService, projects_dir: PathBuf, + writer_probe: Arc, directories: RwLock>, watchers: tokio::sync::Mutex>>, importing_paths: tokio::sync::Mutex>, @@ -157,13 +185,17 @@ pub struct ClaudeTranscriptIngest { impl ClaudeTranscriptIngest { /// Check the kill switch exactly once. A set value of any kind disables /// the entire service and no Claude-store watcher is created. - pub fn spawn(db: DBService, shutdown: CancellationToken) -> Option> { + pub fn spawn( + db: DBService, + writer_probe: Arc, + shutdown: CancellationToken, + ) -> Option> { if std::env::var_os("DISABLE_CLI_TRANSCRIPT_INGEST").is_some() { tracing::info!("CLI transcript ingest disabled by environment"); return None; } let projects_dir = dirs::home_dir()?.join(".claude").join("projects"); - let service = Arc::new(Self::new(db, projects_dir)); + let service = Arc::new(Self::new_with_probe(db, projects_dir, writer_probe)); tokio::spawn(service.clone().run_publisher(shutdown.child_token())); let native_link_updates = native_link_events().subscribe(); @@ -176,11 +208,16 @@ impl ClaudeTranscriptIngest { Some(service) } - fn new(db: DBService, projects_dir: PathBuf) -> Self { + fn new_with_probe( + db: DBService, + projects_dir: PathBuf, + writer_probe: Arc, + ) -> Self { let (feed_updates, _) = broadcast::channel(4096); Self { db, projects_dir, + writer_probe, directories: RwLock::new(HashMap::new()), watchers: tokio::sync::Mutex::new(HashMap::new()), importing_paths: tokio::sync::Mutex::new(HashMap::new()), @@ -199,6 +236,11 @@ impl ClaudeTranscriptIngest { } } + #[cfg(test)] + fn new(db: DBService, projects_dir: PathBuf) -> Self { + Self::new_with_probe(db, projects_dir, Arc::new(TestNoPaneWriterProbe)) + } + pub fn subscribe(&self) -> broadcast::Receiver { self.feed_updates.subscribe() } @@ -244,11 +286,17 @@ impl ClaudeTranscriptIngest { .is_some_and(|context| context.workspace_id == session.workspace_id) }); drop(directories); + let quarantined_files = self.quarantined_paths.lock().unwrap().len() as u64; + let foreign_writer_seen_at = + ClaudeSessionLink::latest_foreign_writer_seen_for_session(&self.db.pool, session_id) + .await? + .map(|time| time.to_rfc3339()); let health = NativeIngestHealth { unknown_kinds: self.unknown_kinds.load(Ordering::Relaxed), rescans: self.rescans.load(Ordering::Relaxed), - quarantined_files: self.quarantined_paths.lock().unwrap().len() as u64, + quarantined_files, watch_degraded, + foreign_writer_seen_at, files, }; Ok(build_projection(&rows, revision, seq, health)) @@ -682,12 +730,17 @@ impl ClaudeTranscriptIngest { }; let mut native_file = CliNativeFile::register(&self.db.pool, ®istration).await?; - let link = ClaudeSessionLink::resolve_or_bind_executor( + let mut link = ClaudeSessionLink::resolve_or_bind_executor( &self.db.pool, claude_session_id, &context.cwd.to_string_lossy(), ) .await?; + if link.is_none() { + link = self + .try_auto_bind_cli_fresh(claude_session_id, context) + .await?; + } if let Some(mutation) = &link { self.apply_link_mutation(mutation).await; } else { @@ -700,6 +753,19 @@ impl ClaudeTranscriptIngest { self.quarantined_paths.lock().unwrap().remove(path); } + let import_context = if link.is_some() { + let report = self + .writer_probe + .probe(context.workspace_id, &context.cwd, Some(claude_session_id)) + .await; + NativeImportContext { + app_pane_absent: !report.probe_failed + && !(report.pane_session_exists && report.agent_running == Some(true)), + } + } else { + NativeImportContext::default() + }; + let verified_hash = if observed_size >= native_file.cursor_offset { verify_last_line_hash(&mut file, &native_file)? } else { @@ -814,13 +880,15 @@ impl ClaudeTranscriptIngest { observed_mtime_ms, }; let imported = if activate_replacement { - let replacement = CliNativeRecord::replace_generation_and_import_batch( - &self.db.pool, - ®istration, - &records, - &cursor, - ) - .await?; + let replacement = + CliNativeRecord::replace_generation_and_import_batch_with_context( + &self.db.pool, + ®istration, + &records, + &cursor, + import_context, + ) + .await?; native_file = replacement.file; activate_replacement = false; self.rescans.fetch_add(1, Ordering::Relaxed); @@ -832,8 +900,14 @@ impl ClaudeTranscriptIngest { tracing::info!(?reason, path = %path.display(), generation = native_file.generation, "rescanned native transcript generation"); replacement.imported } else { - CliNativeRecord::import_batch(&self.db.pool, native_file.id, &records, &cursor) - .await? + CliNativeRecord::import_batch_with_context( + &self.db.pool, + native_file.id, + &records, + &cursor, + import_context, + ) + .await? }; if imported.appended_outbox > 0 { self.publisher_notify.notify_one(); @@ -850,6 +924,54 @@ impl ClaudeTranscriptIngest { Ok(()) } + async fn try_auto_bind_cli_fresh( + &self, + claude_session_id: &str, + context: &DirectoryContext, + ) -> Result, ClaudeTranscriptIngestError> { + let Some(binding) = + CliPaneBinding::find_active_for_workspace(&self.db.pool, context.workspace_id).await? + else { + return Ok(None); + }; + if binding.bound_via != CliPaneBoundVia::CliFresh + || binding + .claude_session_id + .as_deref() + .is_some_and(|sid| sid != claude_session_id) + { + return Ok(None); + } + let report = self + .writer_probe + .probe(context.workspace_id, &context.cwd, None) + .await; + if report.probe_failed + || !report.pane_session_exists + || report.agent_running != Some(true) + || report.sid_evidence != SidEvidence::NoResumeArg + || report.only_active_claude_in_cwd != Some(true) + { + return Ok(None); + } + if binding.claude_session_id.is_none() + && !CliPaneBinding::bind_discovered_sid(&self.db.pool, binding.id, claude_session_id) + .await? + { + return Ok(None); + } + let mutation = ClaudeSessionLink::assign_cli( + &self.db.pool, + claude_session_id, + binding.session_id, + binding.workspace_id, + &context.cwd.to_string_lossy(), + db::models::claude_session_link::ClaudeSessionBoundVia::CliFresh, + ) + .await?; + Ok(Some(mutation)) + } + async fn apply_link_mutation(&self, mutation: &ClaudeSessionLinkMutation) { if mutation.republished_outbox > 0 { self.publisher_notify.notify_one(); diff --git a/crates/services/src/services/claude_transcript_ingest/projection.rs b/crates/services/src/services/claude_transcript_ingest/projection.rs index 439455d9e0f..4a4d84b9faf 100644 --- a/crates/services/src/services/claude_transcript_ingest/projection.rs +++ b/crates/services/src/services/claude_transcript_ingest/projection.rs @@ -63,6 +63,7 @@ pub struct NativeIngestHealth { pub rescans: u64, pub quarantined_files: u64, pub watch_degraded: bool, + pub foreign_writer_seen_at: Option, pub files: Vec, } @@ -139,7 +140,9 @@ pub fn build_projection( .or(row.bound_turn_execution_process_id); let origin = if row.linked_execution_process_id.is_some() { NativeFeedOrigin::Executor - } else if row.bound_turn_execution_process_id.is_some() { + } else if row.bound_turn_execution_process_id.is_some() + || row.bound_queued_message_id.is_some() + { NativeFeedOrigin::App } else { NativeFeedOrigin::Cli diff --git a/crates/services/src/services/claude_transcript_ingest/tests.rs b/crates/services/src/services/claude_transcript_ingest/tests.rs index e876aa7a20d..6c92943f102 100644 --- a/crates/services/src/services/claude_transcript_ingest/tests.rs +++ b/crates/services/src/services/claude_transcript_ingest/tests.rs @@ -1,17 +1,26 @@ use std::{collections::HashSet, fs, path::Path, sync::Arc, time::Duration}; use chrono::{Duration as ChronoDuration, SecondsFormat, Utc}; +use async_trait::async_trait; use db::{ DBService, models::{ claude_session_link::{ClaudeSessionBoundVia, ClaudeSessionLink}, + cli_pane_binding::{CliPaneBinding, CliPaneBoundVia}, cli_ingest_outbox::CliIngestOutbox, - cli_native_file::CliNativeFile, - cli_native_record::{CliNativeRecord, CliNativeRecordDisposition}, + cli_native_file::{CliNativeFile, RegisterCliNativeFile}, + cli_native_record::{ + CliNativeRecord, CliNativeRecordDisposition, ImportedCursor, NativeImportContext, + NewCliNativeRecord, + }, coding_agent_turn::{CodingAgentTurn, CreateCodingAgentTurn}, execution_native_link::ExecutionNativeLink, execution_process::{CreateExecutionProcess, ExecutionProcess, ExecutionProcessRunReason}, session::{CreateSession, Session}, + session_queued_message::{ + QueuedMessageSource, QueuedMessageState, SessionQueuedMessage, + StoreQueuedMessageResult, + }, workspace::{CreateWorkspace, Workspace}, }, }; @@ -31,6 +40,22 @@ use super::{ ClaudeTranscriptIngest, ClaudeTranscriptIngestError, DirectoryContext, NativeFeedOrigin, NativeFeedUpdate, claude_project_slug, first_prompt_snippet, }; +use crate::services::cli_collab::{CliWriterProbe, ProbeReport, SidEvidence}; + +#[derive(Clone)] +struct StaticWriterProbe(ProbeReport); + +#[async_trait] +impl CliWriterProbe for StaticWriterProbe { + async fn probe( + &self, + _workspace_id: Uuid, + _effective_dir: &Path, + _expected_sid: Option<&str>, + ) -> ProbeReport { + self.0.clone() + } +} fn effective_cwd(workspace: &Workspace, session: &Session) -> Option { workspace @@ -172,6 +197,88 @@ fn native_user_record(sid: &str, uuid: &str, text: &str, timestamp: &str) -> Str ) } +async fn register_native_file( + db: &DBService, + workspace_id: Uuid, + sid: &str, +) -> CliNativeFile { + CliNativeFile::register( + &db.pool, + &RegisterCliNativeFile { + claude_session_id: sid, + dir_path: "/tmp/native-import-test", + file_name: &format!("{sid}.jsonl"), + discovered_workspace_id: Some(workspace_id), + dev: 1, + inode: sid.bytes().map(i64::from).sum(), + observed_size: 1, + observed_mtime_ms: None, + }, + ) + .await + .unwrap() +} + +async fn pasted_slot( + db: &DBService, + session_id: Uuid, + sid: &str, + prompt: &str, +) -> SessionQueuedMessage { + let config = serde_json::to_string(&ExecutorConfig::new(BaseCodingAgent::ClaudeCode)).unwrap(); + let row = match SessionQueuedMessage::store( + &db.pool, + session_id, + prompt, + Some(&config), + QueuedMessageSource::Ui, + false, + ) + .await + .unwrap() + { + StoreQueuedMessageResult::Stored(row) => row, + StoreQueuedMessageResult::Conflict(_) => panic!("fixture slot must be empty"), + }; + SessionQueuedMessage::claim(&db.pool, row.id, Some(sid)) + .await + .unwrap() + .unwrap(); + SessionQueuedMessage::mark_pasted(&db.pool, row.id) + .await + .unwrap(); + SessionQueuedMessage::find_by_id(&db.pool, row.id) + .await + .unwrap() + .unwrap() +} + +fn import_record(sid: &str, line_seq: i64, uuid: &str, prompt: &str, at: chrono::DateTime) -> NewCliNativeRecord { + NewCliNativeRecord { + line_seq, + claude_session_id: sid.to_string(), + uuid: Some(uuid.to_string()), + parent_uuid: None, + kind: "user".to_string(), + ts: Some(at.to_rfc3339()), + raw: native_user_record(sid, uuid, prompt, &at.to_rfc3339()).trim().to_string(), + disposition: CliNativeRecordDisposition::Renderable, + user_prompt: Some(prompt.to_string()), + recorded_at: Some(at), + } +} + +fn cursor(next_line_seq: i64) -> ImportedCursor<'static> { + ImportedCursor { + cursor_offset: next_line_seq + 1, + next_line_seq, + last_line_offset: next_line_seq, + last_line_hash: None, + observed_size: next_line_seq + 1, + observed_mtime_ms: None, + } +} + #[test] fn prompt_preview_skips_malformed_lines_within_its_scan_bound() { let temp = TempDir::new().unwrap(); diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index f8f8fe8007f..a28e48484fc 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -615,9 +615,29 @@ impl CliCollabService { } }; if let Some(binding) = binding - && let Some(sid) = binding.claude_session_id && self.transport.pane_alive(session.workspace_id).await { + let sid = match binding.claude_session_id.as_ref() { + Some(sid) => Some(sid.clone()), + None => match self.expected_sid(session_id).await { + Ok(sid) => sid, + Err(error) => { + tracing::warn!(?error, %session_id, "finish hook sid lookup failed closed"); + return Ok(false); + } + }, + }; + let Some(sid) = sid else { + return Ok(false); + }; + if binding.bound_via == db::models::cli_pane_binding::CliPaneBoundVia::CliFresh + && binding.claude_session_id.is_none() + && let Err(error) = + CliPaneBinding::bind_discovered_sid(&self.db.pool, binding.id, &sid).await + { + tracing::warn!(?error, %session_id, "finish hook pane sid update failed closed"); + return Ok(false); + } self.transport .signal_resume_ready(session.workspace_id, &sid) .await; From 92d61c248ff06db6ec7af4c9673eb41779f7ca67 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 13:05:13 +0000 Subject: [PATCH 05/36] fix(lint): simplify pane absence check --- crates/services/src/services/claude_transcript_ingest.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/services/src/services/claude_transcript_ingest.rs b/crates/services/src/services/claude_transcript_ingest.rs index f49a2bfb453..dfc78ef969a 100644 --- a/crates/services/src/services/claude_transcript_ingest.rs +++ b/crates/services/src/services/claude_transcript_ingest.rs @@ -759,8 +759,8 @@ impl ClaudeTranscriptIngest { .probe(context.workspace_id, &context.cwd, Some(claude_session_id)) .await; NativeImportContext { - app_pane_absent: !report.probe_failed - && !(report.pane_session_exists && report.agent_running == Some(true)), + app_pane_absent: !(report.probe_failed + || report.pane_session_exists && report.agent_running == Some(true)), } } else { NativeImportContext::default() From bfb894e3e67b747c0c8d678d9e65c04be23cca1f Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 13:12:01 +0000 Subject: [PATCH 06/36] test(ingest): cover Phase 2 collaboration matching --- ...fbf2b665dbffa15a04a45e69a6780a70eb9c.json} | 4 +- crates/db/src/models/cli_native_record.rs | 4 +- .../claude_transcript_ingest/tests.rs | 460 +++++++++++++++++- 3 files changed, 453 insertions(+), 15 deletions(-) rename crates/db/.sqlx/{query-34f46a00600ed636b8f37ccfdbc718a81ba5d080a5e6e1a6b9150359f6249eb1.json => query-9c317d6ecf11ea26e04a8b4f7d63fbf2b665dbffa15a04a45e69a6780a70eb9c.json} (69%) diff --git a/crates/db/.sqlx/query-34f46a00600ed636b8f37ccfdbc718a81ba5d080a5e6e1a6b9150359f6249eb1.json b/crates/db/.sqlx/query-9c317d6ecf11ea26e04a8b4f7d63fbf2b665dbffa15a04a45e69a6780a70eb9c.json similarity index 69% rename from crates/db/.sqlx/query-34f46a00600ed636b8f37ccfdbc718a81ba5d080a5e6e1a6b9150359f6249eb1.json rename to crates/db/.sqlx/query-9c317d6ecf11ea26e04a8b4f7d63fbf2b665dbffa15a04a45e69a6780a70eb9c.json index 91d593ec38d..7363b7d7e1a 100644 --- a/crates/db/.sqlx/query-34f46a00600ed636b8f37ccfdbc718a81ba5d080a5e6e1a6b9150359f6249eb1.json +++ b/crates/db/.sqlx/query-9c317d6ecf11ea26e04a8b4f7d63fbf2b665dbffa15a04a45e69a6780a70eb9c.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT id AS \"id!: Uuid\"\n FROM session_queued_messages\n WHERE session_id = $1\n AND state IN ('pasting', 'pasted')\n AND prompt = $2\n AND (claude_session_id IS NULL OR claude_session_id = $3)\n AND pasted_at IS NOT NULL\n AND pasted_at >= $4\n AND pasted_at <= $5\n ORDER BY pasted_at DESC\n LIMIT 1", + "query": "SELECT id AS \"id!: Uuid\"\n FROM session_queued_messages\n WHERE session_id = $1\n AND state IN ('pasting', 'pasted')\n AND prompt = $2\n AND (claude_session_id IS NULL OR claude_session_id = $3)\n AND pasted_at IS NOT NULL\n AND julianday(pasted_at) >= julianday($4)\n AND julianday(pasted_at) <= julianday($5)\n ORDER BY pasted_at DESC\n LIMIT 1", "describe": { "columns": [ { @@ -16,5 +16,5 @@ true ] }, - "hash": "34f46a00600ed636b8f37ccfdbc718a81ba5d080a5e6e1a6b9150359f6249eb1" + "hash": "9c317d6ecf11ea26e04a8b4f7d63fbf2b665dbffa15a04a45e69a6780a70eb9c" } diff --git a/crates/db/src/models/cli_native_record.rs b/crates/db/src/models/cli_native_record.rs index 5ae8e40fb80..c5952147bc5 100644 --- a/crates/db/src/models/cli_native_record.rs +++ b/crates/db/src/models/cli_native_record.rs @@ -494,8 +494,8 @@ impl CliNativeRecord { AND prompt = $2 AND (claude_session_id IS NULL OR claude_session_id = $3) AND pasted_at IS NOT NULL - AND pasted_at >= $4 - AND pasted_at <= $5 + AND julianday(pasted_at) >= julianday($4) + AND julianday(pasted_at) <= julianday($5) ORDER BY pasted_at DESC LIMIT 1"#, session_id, diff --git a/crates/services/src/services/claude_transcript_ingest/tests.rs b/crates/services/src/services/claude_transcript_ingest/tests.rs index 6c92943f102..72ce6aed086 100644 --- a/crates/services/src/services/claude_transcript_ingest/tests.rs +++ b/crates/services/src/services/claude_transcript_ingest/tests.rs @@ -1,25 +1,24 @@ use std::{collections::HashSet, fs, path::Path, sync::Arc, time::Duration}; -use chrono::{Duration as ChronoDuration, SecondsFormat, Utc}; use async_trait::async_trait; +use chrono::{Duration as ChronoDuration, SecondsFormat, Utc}; use db::{ DBService, models::{ claude_session_link::{ClaudeSessionBoundVia, ClaudeSessionLink}, - cli_pane_binding::{CliPaneBinding, CliPaneBoundVia}, cli_ingest_outbox::CliIngestOutbox, cli_native_file::{CliNativeFile, RegisterCliNativeFile}, cli_native_record::{ CliNativeRecord, CliNativeRecordDisposition, ImportedCursor, NativeImportContext, NewCliNativeRecord, }, + cli_pane_binding::{CliPaneBinding, CliPaneBoundVia}, coding_agent_turn::{CodingAgentTurn, CreateCodingAgentTurn}, execution_native_link::ExecutionNativeLink, execution_process::{CreateExecutionProcess, ExecutionProcess, ExecutionProcessRunReason}, session::{CreateSession, Session}, session_queued_message::{ - QueuedMessageSource, QueuedMessageState, SessionQueuedMessage, - StoreQueuedMessageResult, + QueuedMessageSource, QueuedMessageState, SessionQueuedMessage, StoreQueuedMessageResult, }, workspace::{CreateWorkspace, Workspace}, }, @@ -197,11 +196,7 @@ fn native_user_record(sid: &str, uuid: &str, text: &str, timestamp: &str) -> Str ) } -async fn register_native_file( - db: &DBService, - workspace_id: Uuid, - sid: &str, -) -> CliNativeFile { +async fn register_native_file(db: &DBService, workspace_id: Uuid, sid: &str) -> CliNativeFile { CliNativeFile::register( &db.pool, &RegisterCliNativeFile { @@ -253,7 +248,13 @@ async fn pasted_slot( .unwrap() } -fn import_record(sid: &str, line_seq: i64, uuid: &str, prompt: &str, at: chrono::DateTime) -> NewCliNativeRecord { +fn import_record( + sid: &str, + line_seq: i64, + uuid: &str, + prompt: &str, + at: chrono::DateTime, +) -> NewCliNativeRecord { NewCliNativeRecord { line_seq, claude_session_id: sid.to_string(), @@ -261,7 +262,9 @@ fn import_record(sid: &str, line_seq: i64, uuid: &str, prompt: &str, at: chrono: parent_uuid: None, kind: "user".to_string(), ts: Some(at.to_rfc3339()), - raw: native_user_record(sid, uuid, prompt, &at.to_rfc3339()).trim().to_string(), + raw: native_user_record(sid, uuid, prompt, &at.to_rfc3339()) + .trim() + .to_string(), disposition: CliNativeRecordDisposition::Renderable, user_prompt: Some(prompt.to_string()), recorded_at: Some(at), @@ -279,6 +282,441 @@ fn cursor(next_line_seq: i64) -> ImportedCursor<'static> { } } +fn writer_report( + pane_session_exists: bool, + agent_running: Option, + sid_evidence: SidEvidence, + only_active_claude_in_cwd: Option, +) -> ProbeReport { + ProbeReport { + pane_session_exists, + agent_running, + sid_evidence, + probe_failed: false, + only_active_claude_in_cwd, + } +} + +async fn import_foreign_writer_case(report: ProbeReport, executor_running: bool) -> (bool, bool) { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("worktree"); + let projects_dir = temp.path().join("projects"); + fs::create_dir_all(&workspace_root).unwrap(); + let db = test_db().await; + let (workspace, session) = create_workspace_and_session(&db, &workspace_root).await; + let cwd = effective_cwd(&workspace, &session).unwrap(); + let native_dir = store_dir(&projects_dir, &cwd); + fs::create_dir_all(&native_dir).unwrap(); + let sid = "45454545-4545-4545-8545-454545454545"; + ClaudeSessionLink::assign_manual(&db.pool, sid, session.id, &cwd.to_string_lossy()) + .await + .unwrap() + .unwrap(); + if executor_running { + create_coding_turn(&db, session.id, "unrelated running prompt").await; + } + fs::write( + native_dir.join(format!("{sid}.jsonl")), + native_user_record( + sid, + "foreign-user", + "written elsewhere", + "2026-07-21T12:00:00Z", + ), + ) + .unwrap(); + + let service = Arc::new(ClaudeTranscriptIngest::new_with_probe( + db.clone(), + projects_dir, + Arc::new(StaticWriterProbe(report)), + )); + service + .reconcile_registry(false, CancellationToken::new()) + .await + .unwrap(); + + let rows = CliNativeRecord::list_for_session(&db.pool, session.id) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].linked_execution_process_id, None); + assert_eq!(rows[0].bound_turn_execution_process_id, None); + assert_eq!(rows[0].bound_queued_message_id, None); + let link = ClaudeSessionLink::find(&db.pool, sid) + .await + .unwrap() + .unwrap(); + let health = service.snapshot(session.id).await.unwrap().health; + ( + link.foreign_writer_seen_at.is_some(), + health.foreign_writer_seen_at.is_some(), + ) +} + +async fn import_cli_fresh_case( + release_binding: bool, + report: ProbeReport, +) -> (Option, CliPaneBinding, u64) { + let temp = TempDir::new().unwrap(); + let workspace_root = temp.path().join("worktree"); + let projects_dir = temp.path().join("projects"); + fs::create_dir_all(&workspace_root).unwrap(); + let db = test_db().await; + let (workspace, session) = create_workspace_and_session(&db, &workspace_root).await; + let cwd = effective_cwd(&workspace, &session).unwrap(); + let native_dir = store_dir(&projects_dir, &cwd); + fs::create_dir_all(&native_dir).unwrap(); + let sid = "46464646-4646-4646-8646-464646464646"; + let binding = CliPaneBinding::record_launch( + &db.pool, + workspace.id, + session.id, + None, + CliPaneBoundVia::CliFresh, + ) + .await + .unwrap(); + if release_binding { + assert!(CliPaneBinding::release(&db.pool, binding.id).await.unwrap()); + } + fs::write( + native_dir.join(format!("{sid}.jsonl")), + native_user_record( + sid, + "cli-fresh-user", + "new CLI conversation", + "2026-07-21T12:00:00Z", + ), + ) + .unwrap(); + + let service = Arc::new(ClaudeTranscriptIngest::new_with_probe( + db.clone(), + projects_dir, + Arc::new(StaticWriterProbe(report)), + )); + service + .reconcile_registry(false, CancellationToken::new()) + .await + .unwrap(); + + let link = ClaudeSessionLink::find(&db.pool, sid).await.unwrap(); + let binding = CliPaneBinding::find_by_id(&db.pool, binding.id) + .await + .unwrap() + .unwrap(); + let quarantined_files = service + .snapshot(session.id) + .await + .unwrap() + .health + .quarantined_files; + (link, binding, quarantined_files) +} + +#[tokio::test] +async fn foreign_writer_is_recorded_and_surfaced_when_no_app_writer_is_live() { + let observed = import_foreign_writer_case( + writer_report(false, Some(false), SidEvidence::Unknown, Some(false)), + false, + ) + .await; + assert_eq!(observed, (true, true)); +} + +#[tokio::test] +async fn foreign_writer_classifier_fails_safe_for_executor_pane_and_probe_failure() { + let executor_running = import_foreign_writer_case( + writer_report(false, Some(false), SidEvidence::Unknown, Some(false)), + true, + ) + .await; + assert_eq!(executor_running, (false, false)); + + let live_pane = import_foreign_writer_case( + writer_report(true, Some(true), SidEvidence::Unknown, Some(true)), + false, + ) + .await; + assert_eq!(live_pane, (false, false)); + + let probe_failure = import_foreign_writer_case(ProbeReport::failed(), false).await; + assert_eq!(probe_failure, (false, false)); +} + +#[tokio::test] +async fn cli_fresh_file_auto_binds_to_the_only_live_unreleased_pane() { + let (link, binding, quarantined_files) = import_cli_fresh_case( + false, + writer_report(true, Some(true), SidEvidence::NoResumeArg, Some(true)), + ) + .await; + let link = link.unwrap(); + assert_eq!(link.session_id, binding.session_id); + assert_eq!(link.bound_via, ClaudeSessionBoundVia::CliFresh); + assert_eq!( + binding.claude_session_id.as_deref(), + Some(link.claude_session_id.as_str()) + ); + assert!(binding.released_at.is_none()); + assert_eq!(quarantined_files, 0); +} + +#[tokio::test] +async fn cli_fresh_file_quarantines_for_released_dead_or_nonexclusive_panes() { + let healthy_report = || writer_report(true, Some(true), SidEvidence::NoResumeArg, Some(true)); + let cases = [ + (true, healthy_report()), + ( + false, + writer_report(false, Some(false), SidEvidence::NoResumeArg, Some(true)), + ), + ( + false, + writer_report(true, Some(true), SidEvidence::NoResumeArg, Some(false)), + ), + ]; + + for (release_binding, report) in cases { + let (link, binding, quarantined_files) = + import_cli_fresh_case(release_binding, report).await; + assert!(link.is_none()); + assert!(binding.claude_session_id.is_none()); + assert_eq!(quarantined_files, 1); + } +} + +#[tokio::test] +async fn paste_ack_binding_and_slot_import_are_atomic_and_project_as_app_origin() { + let temp = TempDir::new().unwrap(); + let db = test_db().await; + let (workspace, session) = create_workspace_and_session(&db, temp.path()).await; + let sid = "41414141-4141-4141-8141-414141414141"; + let cwd = effective_cwd(&workspace, &session).unwrap(); + ClaudeSessionLink::assign_manual(&db.pool, sid, session.id, &cwd.to_string_lossy()) + .await + .unwrap() + .unwrap(); + let native_file = register_native_file(&db, workspace.id, sid).await; + let slot = pasted_slot(&db, session.id, sid, "deliver through CLI").await; + let recorded_at = slot.pasted_at.unwrap() + ChronoDuration::seconds(1); + let record = import_record( + sid, + 0, + "delivery-bound-user", + "deliver through CLI", + recorded_at, + ); + + sqlx::query( + "CREATE TRIGGER fail_native_cursor BEFORE UPDATE ON cli_native_files \ + BEGIN SELECT RAISE(ABORT, 'cursor failure'); END", + ) + .execute(&db.pool) + .await + .unwrap(); + assert!( + CliNativeRecord::import_batch_with_context( + &db.pool, + native_file.id, + std::slice::from_ref(&record), + &cursor(1), + NativeImportContext::default(), + ) + .await + .is_err() + ); + assert_eq!( + CliNativeRecord::count_for_file(&db.pool, native_file.id) + .await + .unwrap(), + 0 + ); + let rolled_back_slot = SessionQueuedMessage::find_by_id(&db.pool, slot.id) + .await + .unwrap() + .unwrap(); + assert_eq!(rolled_back_slot.state, QueuedMessageState::Pasted); + assert!(rolled_back_slot.acked_at.is_none()); + + sqlx::query("DROP TRIGGER fail_native_cursor") + .execute(&db.pool) + .await + .unwrap(); + CliNativeRecord::import_batch_with_context( + &db.pool, + native_file.id, + &[record], + &cursor(1), + NativeImportContext::default(), + ) + .await + .unwrap(); + + let rows = CliNativeRecord::list_for_session(&db.pool, session.id) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].bound_queued_message_id, Some(slot.id)); + let imported_slot = SessionQueuedMessage::find_by_id(&db.pool, slot.id) + .await + .unwrap() + .unwrap(); + assert_eq!(imported_slot.state, QueuedMessageState::Imported); + assert!(imported_slot.acked_at.is_some()); + assert_eq!(imported_slot.prompt, "deliver through CLI"); + + let service = ClaudeTranscriptIngest::new(db, temp.path().join("projects")); + let entry = service + .snapshot(session.id) + .await + .unwrap() + .entries + .into_iter() + .find(|entry| entry.uuid.as_deref() == Some("delivery-bound-user")) + .unwrap(); + assert_eq!(entry.origin, NativeFeedOrigin::App); +} + +#[tokio::test] +async fn paste_ack_matcher_enforces_paste_window_bounds() { + let temp = TempDir::new().unwrap(); + let db = test_db().await; + let (workspace, early_session) = create_workspace_and_session(&db, temp.path()).await; + let cwd = effective_cwd(&workspace, &early_session).unwrap(); + + let early_sid = "42424242-4242-4242-8242-424242424242"; + ClaudeSessionLink::assign_manual( + &db.pool, + early_sid, + early_session.id, + &cwd.to_string_lossy(), + ) + .await + .unwrap() + .unwrap(); + let early_file = register_native_file(&db, workspace.id, early_sid).await; + let early_slot = pasted_slot(&db, early_session.id, early_sid, "same prompt").await; + let before_skew = early_slot.pasted_at.unwrap() - ChronoDuration::seconds(6); + CliNativeRecord::import_batch( + &db.pool, + early_file.id, + &[import_record( + early_sid, + 0, + "before-paste-skew", + "same prompt", + before_skew, + )], + &cursor(1), + ) + .await + .unwrap(); + let early_rows = CliNativeRecord::list_for_session(&db.pool, early_session.id) + .await + .unwrap(); + assert_eq!(early_rows[0].bound_queued_message_id, None); + assert_eq!( + SessionQueuedMessage::find_by_id(&db.pool, early_slot.id) + .await + .unwrap() + .unwrap() + .state, + QueuedMessageState::Pasted + ); + + let stale_session = create_session(&db, workspace.id).await; + let stale_sid = "43434343-4343-4343-8343-434343434343"; + ClaudeSessionLink::assign_manual( + &db.pool, + stale_sid, + stale_session.id, + &cwd.to_string_lossy(), + ) + .await + .unwrap() + .unwrap(); + let stale_file = register_native_file(&db, workspace.id, stale_sid).await; + let stale_slot = pasted_slot(&db, stale_session.id, stale_sid, "same prompt").await; + let after_timeout = + stale_slot.pasted_at.unwrap() + ChronoDuration::minutes(15) + ChronoDuration::seconds(1); + CliNativeRecord::import_batch( + &db.pool, + stale_file.id, + &[import_record( + stale_sid, + 0, + "after-paste-window", + "same prompt", + after_timeout, + )], + &cursor(1), + ) + .await + .unwrap(); + let stale_rows = CliNativeRecord::list_for_session(&db.pool, stale_session.id) + .await + .unwrap(); + assert_eq!(stale_rows[0].bound_queued_message_id, None); + assert_eq!( + SessionQueuedMessage::find_by_id(&db.pool, stale_slot.id) + .await + .unwrap() + .unwrap() + .state, + QueuedMessageState::Pasted + ); +} + +#[tokio::test] +async fn paste_ack_matcher_excludes_executor_linked_native_records() { + let temp = TempDir::new().unwrap(); + let db = test_db().await; + let (workspace, session) = create_workspace_and_session(&db, temp.path()).await; + let sid = "44444444-4444-4444-8444-444444444444"; + let cwd = effective_cwd(&workspace, &session).unwrap(); + ClaudeSessionLink::assign_manual(&db.pool, sid, session.id, &cwd.to_string_lossy()) + .await + .unwrap() + .unwrap(); + let native_file = register_native_file(&db, workspace.id, sid).await; + let slot = pasted_slot(&db, session.id, sid, "linked prompt").await; + let process_id = create_coding_turn(&db, session.id, "linked prompt").await; + ExecutionNativeLink::insert(&db.pool, process_id, "executor-linked-user") + .await + .unwrap(); + let recorded_at = slot.pasted_at.unwrap() + ChronoDuration::seconds(1); + + CliNativeRecord::import_batch( + &db.pool, + native_file.id, + &[import_record( + sid, + 0, + "executor-linked-user", + "linked prompt", + recorded_at, + )], + &cursor(1), + ) + .await + .unwrap(); + + let rows = CliNativeRecord::list_for_session(&db.pool, session.id) + .await + .unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].linked_execution_process_id, Some(process_id)); + assert_eq!(rows[0].bound_queued_message_id, None); + let unacked_slot = SessionQueuedMessage::find_by_id(&db.pool, slot.id) + .await + .unwrap() + .unwrap(); + assert_eq!(unacked_slot.state, QueuedMessageState::Pasted); + assert!(unacked_slot.acked_at.is_none()); +} + #[test] fn prompt_preview_skips_malformed_lines_within_its_scan_bound() { let temp = TempDir::new().unwrap(); From b2aa7da6c49699bf1526872a32d14842d83e15e1 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 13:20:16 +0000 Subject: [PATCH 07/36] test(services): cover CLI collaboration state machines --- ...da870c127ddf6317e95e82ebb9233739953c.json} | 4 +- ...728739ce41269ead752d56d93d08f505dba7.json} | 4 +- .../db/src/models/session_queued_message.rs | 6 +- .../src/models/workspace_spawn_reservation.rs | 26 +- crates/services/src/services/cli_collab.rs | 634 +++++++++++++++++- 5 files changed, 644 insertions(+), 30 deletions(-) rename crates/db/.sqlx/{query-6112db769dea7cfee0deb4644b11978ae11f79c1bde13febfc9d619cbe7fcbc9.json => query-62b1de1e117e40ede73b8f317a2dda870c127ddf6317e95e82ebb9233739953c.json} (72%) rename crates/db/.sqlx/{query-953a08426ac82ecb9b581115fb7b9d85febfee88931db06d1757836165e9e35c.json => query-faf9ba8cfbe810d165d289579c97728739ce41269ead752d56d93d08f505dba7.json} (71%) diff --git a/crates/db/.sqlx/query-6112db769dea7cfee0deb4644b11978ae11f79c1bde13febfc9d619cbe7fcbc9.json b/crates/db/.sqlx/query-62b1de1e117e40ede73b8f317a2dda870c127ddf6317e95e82ebb9233739953c.json similarity index 72% rename from crates/db/.sqlx/query-6112db769dea7cfee0deb4644b11978ae11f79c1bde13febfc9d619cbe7fcbc9.json rename to crates/db/.sqlx/query-62b1de1e117e40ede73b8f317a2dda870c127ddf6317e95e82ebb9233739953c.json index 7602787b343..c211595cf2f 100644 --- a/crates/db/.sqlx/query-6112db769dea7cfee0deb4644b11978ae11f79c1bde13febfc9d619cbe7fcbc9.json +++ b/crates/db/.sqlx/query-62b1de1e117e40ede73b8f317a2dda870c127ddf6317e95e82ebb9233739953c.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = 'delivery interrupted; queued for retry',\n claude_session_id = NULL,\n pasted_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE state = 'pasting' AND updated_at <= $1", + "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = 'delivery interrupted; queued for retry',\n claude_session_id = NULL,\n pasted_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE state = 'pasting'\n AND julianday(updated_at) <= julianday($1)", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "6112db769dea7cfee0deb4644b11978ae11f79c1bde13febfc9d619cbe7fcbc9" + "hash": "62b1de1e117e40ede73b8f317a2dda870c127ddf6317e95e82ebb9233739953c" } diff --git a/crates/db/.sqlx/query-953a08426ac82ecb9b581115fb7b9d85febfee88931db06d1757836165e9e35c.json b/crates/db/.sqlx/query-faf9ba8cfbe810d165d289579c97728739ce41269ead752d56d93d08f505dba7.json similarity index 71% rename from crates/db/.sqlx/query-953a08426ac82ecb9b581115fb7b9d85febfee88931db06d1757836165e9e35c.json rename to crates/db/.sqlx/query-faf9ba8cfbe810d165d289579c97728739ce41269ead752d56d93d08f505dba7.json index 317a3284098..9d535646f6a 100644 --- a/crates/db/.sqlx/query-953a08426ac82ecb9b581115fb7b9d85febfee88931db06d1757836165e9e35c.json +++ b/crates/db/.sqlx/query-faf9ba8cfbe810d165d289579c97728739ce41269ead752d56d93d08f505dba7.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = 'CLI submission was not acknowledged; queued for retry',\n claude_session_id = NULL,\n pasted_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE state = 'pasted' AND pasted_at <= $1", + "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = 'CLI submission was not acknowledged; queued for retry',\n claude_session_id = NULL,\n pasted_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE state = 'pasted'\n AND julianday(pasted_at) <= julianday($1)", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "953a08426ac82ecb9b581115fb7b9d85febfee88931db06d1757836165e9e35c" + "hash": "faf9ba8cfbe810d165d289579c97728739ce41269ead752d56d93d08f505dba7" } diff --git a/crates/db/src/models/session_queued_message.rs b/crates/db/src/models/session_queued_message.rs index 26ff8245471..78670c6aa7e 100644 --- a/crates/db/src/models/session_queued_message.rs +++ b/crates/db/src/models/session_queued_message.rs @@ -341,7 +341,8 @@ impl SessionQueuedMessage { claude_session_id = NULL, pasted_at = NULL, updated_at = datetime('now', 'subsec') - WHERE state = 'pasting' AND updated_at <= $1"#, + WHERE state = 'pasting' + AND julianday(updated_at) <= julianday($1)"#, pasting_cutoff ) .execute(pool) @@ -356,7 +357,8 @@ impl SessionQueuedMessage { claude_session_id = NULL, pasted_at = NULL, updated_at = datetime('now', 'subsec') - WHERE state = 'pasted' AND pasted_at <= $1"#, + WHERE state = 'pasted' + AND julianday(pasted_at) <= julianday($1)"#, pasted_cutoff ) .execute(pool) diff --git a/crates/db/src/models/workspace_spawn_reservation.rs b/crates/db/src/models/workspace_spawn_reservation.rs index 779c7d3ca0d..1eddceef905 100644 --- a/crates/db/src/models/workspace_spawn_reservation.rs +++ b/crates/db/src/models/workspace_spawn_reservation.rs @@ -119,7 +119,7 @@ mod tests { use crate::models::workspace::{CreateWorkspace, Workspace}; #[tokio::test] - async fn reservation_fence_and_ttl_prevent_overlapping_spawns() { + async fn reservation_fence_release_and_ttl_prevent_overlapping_spawns() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") @@ -165,17 +165,22 @@ mod tests { .await .unwrap() ); + assert!( + WorkspaceSpawnReservation::release(&pool, workspace_id, &first.fence) + .await + .unwrap() + ); let second = WorkspaceSpawnReservation::acquire_at( &pool, workspace_id, SpawnReservationHolder::Cli, - now + Duration::seconds(16), + now + Duration::seconds(14), Duration::seconds(15), ) .await .unwrap() - .expect("expired reservation must be replaced"); + .expect("matching release must free the reservation"); assert_ne!(first.fence, second.fence); assert_eq!(second.holder, SpawnReservationHolder::Cli); assert!( @@ -183,8 +188,21 @@ mod tests { .await .unwrap() ); + + let third = WorkspaceSpawnReservation::acquire_at( + &pool, + workspace_id, + SpawnReservationHolder::Executor, + now + Duration::seconds(30), + Duration::seconds(15), + ) + .await + .unwrap() + .expect("expired reservation must be replaced"); + assert_ne!(second.fence, third.fence); + assert_eq!(third.holder, SpawnReservationHolder::Executor); assert!( - WorkspaceSpawnReservation::release(&pool, workspace_id, &second.fence) + WorkspaceSpawnReservation::release(&pool, workspace_id, &third.fence) .await .unwrap() ); diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index a28e48484fc..00af71b9b02 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -825,6 +825,7 @@ mod tests { use db::models::{ claude_session_link::ClaudeSessionLink, + cli_native_file::{CliNativeFile, RegisterCliNativeFile}, cli_pane_binding::{CliPaneBinding, CliPaneBoundVia}, execution_process::{CreateExecutionProcess, ExecutionProcessRunReason}, session::CreateSession, @@ -874,6 +875,44 @@ mod tests { async fn signal_resume_ready(&self, _workspace_id: Uuid, _sid: &str) {} } + #[derive(Debug, Default)] + struct TransportState { + pasted_prompts: Vec, + observed_states: Vec, + } + + struct RecordingTransport { + db: DBService, + session_id: Uuid, + state: Arc>, + paste_succeeds: bool, + } + + #[async_trait] + impl CliPasteTransport for RecordingTransport { + async fn paste_and_submit(&self, _workspace_id: Uuid, text: &str) -> bool { + let state = SessionQueuedMessage::find_active(&self.db.pool, self.session_id) + .await + .unwrap() + .map(|row| row.state) + .expect("paste transport must observe an active delivery row"); + let mut observations = self.state.lock().unwrap(); + observations.pasted_prompts.push(text.to_string()); + observations.observed_states.push(state); + self.paste_succeeds + } + + async fn pane_alive(&self, _workspace_id: Uuid) -> bool { + true + } + + async fn agent_running(&self, _workspace_id: Uuid) -> Option { + Some(true) + } + + async fn signal_resume_ready(&self, _workspace_id: Uuid, _sid: &str) {} + } + struct NeverDispatcher; #[async_trait] @@ -888,6 +927,38 @@ mod tests { } } + #[derive(Debug, Default)] + struct DispatcherState { + prompts: Vec, + reservation_seen: bool, + } + + struct RecordingDispatcher { + db: DBService, + state: Arc>, + } + + #[async_trait] + impl CliExecutorDispatcher for RecordingDispatcher { + async fn dispatch( + &self, + session: &Session, + prompt: &str, + _executor_config: &ExecutorConfig, + ) -> AnyhowResult { + let reservation_seen = + WorkspaceSpawnReservation::find(&self.db.pool, session.workspace_id) + .await? + .is_some(); + { + let mut state = self.state.lock().unwrap(); + state.prompts.push(prompt.to_string()); + state.reservation_seen = reservation_seen; + } + Ok(create_running_executor(&self.db, session.id, prompt).await) + } + } + async fn fixture() -> (DBService, Workspace, Session) { let pool = SqlitePoolOptions::new() .max_connections(1) @@ -928,14 +999,19 @@ mod tests { (db, workspace, session) } - fn service(db: DBService, report: ProbeReport) -> (CliCollabService, FakeProbe) { + fn service_with_components( + db: DBService, + report: ProbeReport, + transport: Arc, + dispatcher: Arc, + ) -> (CliCollabService, FakeProbe) { let probe = FakeProbe(Arc::new(StdMutex::new(report))); ( CliCollabService { db, probe: Arc::new(probe.clone()), - transport: Arc::new(FakeTransport), - dispatcher: Arc::new(NeverDispatcher), + transport, + dispatcher, ingest: None, session_locks: Mutex::new(HashMap::new()), notify: Notify::new(), @@ -946,6 +1022,15 @@ mod tests { ) } + fn service(db: DBService, report: ProbeReport) -> (CliCollabService, FakeProbe) { + service_with_components( + db, + report, + Arc::new(FakeTransport), + Arc::new(NeverDispatcher), + ) + } + fn report( pane_session_exists: bool, agent_running: Option, @@ -960,8 +1045,83 @@ mod tests { } } + fn executor_config() -> ExecutorConfig { + ExecutorConfig::new(BaseCodingAgent::ClaudeCode) + } + + async fn create_running_executor( + db: &DBService, + session_id: Uuid, + prompt: &str, + ) -> ExecutionProcess { + let action = ExecutorAction::new( + ExecutorActionType::CodingAgentInitialRequest(CodingAgentInitialRequest { + prompt: prompt.to_string(), + executor_config: executor_config(), + working_dir: None, + }), + None, + ); + ExecutionProcess::create( + &db.pool, + &CreateExecutionProcess { + session_id, + executor_action: action, + run_reason: ExecutionProcessRunReason::CodingAgent, + }, + Uuid::new_v4(), + &[], + ) + .await + .unwrap() + } + + async fn store_message( + db: &DBService, + session_id: Uuid, + prompt: &str, + source: QueuedMessageSource, + ) -> SessionQueuedMessage { + let serialized = serde_json::to_string(&executor_config()).unwrap(); + match SessionQueuedMessage::store( + &db.pool, + session_id, + prompt, + Some(&serialized), + source, + false, + ) + .await + .unwrap() + { + StoreQueuedMessageResult::Stored(row) => row, + StoreQueuedMessageResult::Conflict(_) => panic!("fixture slot must be empty"), + } + } + + async fn bind_confirmed_cli( + db: &DBService, + workspace: &Workspace, + session: &Session, + sid: &str, + ) { + CliPaneBinding::record_launch( + &db.pool, + workspace.id, + session.id, + Some(sid), + CliPaneBoundVia::CliResume, + ) + .await + .unwrap(); + ClaudeSessionLink::assign_manual(&db.pool, sid, session.id, "/tmp/collab-fixture") + .await + .unwrap() + .unwrap(); + } + #[tokio::test] - async fn lease_derivation_covers_executor_cli_ambiguous_free_and_db_failure() { + async fn lease_derivation_covers_executor_confirmed_cli_ambiguous_and_free() { let (db, workspace, session) = fixture().await; let (service, probe) = service(db.clone(), report(false, Some(false), SidEvidence::Unknown)); @@ -1003,29 +1163,463 @@ mod tests { WriterLease::CliAmbiguous ); - let action = ExecutorAction::new( - ExecutorActionType::CodingAgentInitialRequest(CodingAgentInitialRequest { - prompt: "running".to_string(), - executor_config: ExecutorConfig::new(BaseCodingAgent::ClaudeCode), - working_dir: None, - }), - None, + create_running_executor(&db, session.id, "running").await; + assert_eq!(service.derive_lease(&session).await, WriterLease::Executor); + } + + #[tokio::test] + async fn lease_derivation_fails_closed_on_database_and_probe_errors() { + let (db, _workspace, session) = fixture().await; + let (service, probe) = + service(db.clone(), report(false, Some(false), SidEvidence::Unknown)); + + *probe.0.lock().unwrap() = ProbeReport::failed(); + assert_eq!(service.derive_lease(&session).await, WriterLease::Busy); + + db.pool.close().await; + assert_eq!(service.derive_lease(&session).await, WriterLease::Busy); + } + + #[tokio::test] + async fn dispatch_gate_queues_while_executor_is_running() { + let (db, _workspace, session) = fixture().await; + create_running_executor(&db, session.id, "already running").await; + let transport_state = Arc::new(StdMutex::new(TransportState::default())); + let transport = Arc::new(RecordingTransport { + db: db.clone(), + session_id: session.id, + state: transport_state.clone(), + paste_succeeds: true, + }); + let (service, _) = service_with_components( + db.clone(), + report(false, Some(false), SidEvidence::Unknown), + transport, + Arc::new(NeverDispatcher), ); - ExecutionProcess::create( + + let outcome = service + .dispatch_gate( + &session, + "wait for executor".to_string(), + executor_config(), + QueuedMessageSource::Ui, + false, + ) + .await + .unwrap(); + let DispatchOutcome::Queued { status } = outcome else { + panic!("running executor must queue the prompt"); + }; + let message = status.message().unwrap(); + assert_eq!(message.data.message, "wait for executor"); + assert_eq!(message.state, QueuedMessageState::Queued); + assert!(transport_state.lock().unwrap().pasted_prompts.is_empty()); + } + + #[tokio::test] + async fn dispatch_gate_routes_confirmed_cli_through_pasting_to_pasted() { + let (db, workspace, session) = fixture().await; + let sid = "22222222-2222-4222-8222-222222222222"; + bind_confirmed_cli(&db, &workspace, &session, sid).await; + let transport_state = Arc::new(StdMutex::new(TransportState::default())); + let transport = Arc::new(RecordingTransport { + db: db.clone(), + session_id: session.id, + state: transport_state.clone(), + paste_succeeds: true, + }); + let (service, _) = service_with_components( + db.clone(), + report( + true, + Some(true), + SidEvidence::ConfirmedResume(sid.to_string()), + ), + transport, + Arc::new(NeverDispatcher), + ); + + let outcome = service + .dispatch_gate( + &session, + "route to CLI".to_string(), + executor_config(), + QueuedMessageSource::Ui, + false, + ) + .await + .unwrap(); + let DispatchOutcome::RoutedToCli { delivery } = outcome else { + panic!("confirmed CLI must receive the prompt"); + }; + let message = delivery.message().unwrap(); + assert_eq!(message.data.message, "route to CLI"); + assert_eq!(message.state, QueuedMessageState::Pasted); + assert_eq!(message.claude_session_id.as_deref(), Some(sid)); + let observations = transport_state.lock().unwrap(); + assert_eq!(observations.pasted_prompts, ["route to CLI"]); + assert_eq!(observations.observed_states, [QueuedMessageState::Pasting]); + } + + #[tokio::test] + async fn dispatch_gate_queues_ambiguous_sid_without_pasting() { + let (db, workspace, session) = fixture().await; + let sid = "23232323-2323-4232-8232-232323232323"; + bind_confirmed_cli(&db, &workspace, &session, sid).await; + let transport_state = Arc::new(StdMutex::new(TransportState::default())); + let transport = Arc::new(RecordingTransport { + db: db.clone(), + session_id: session.id, + state: transport_state.clone(), + paste_succeeds: true, + }); + let (service, _) = service_with_components( + db, + report(true, Some(true), SidEvidence::Ambiguous), + transport, + Arc::new(NeverDispatcher), + ); + + let outcome = service + .dispatch_gate( + &session, + "do not paste ambiguously".to_string(), + executor_config(), + QueuedMessageSource::Ui, + false, + ) + .await + .unwrap(); + let DispatchOutcome::Queued { status } = outcome else { + panic!("ambiguous CLI sid must queue the prompt"); + }; + assert_eq!( + status.message().unwrap().data.message, + "do not paste ambiguously" + ); + assert!(transport_state.lock().unwrap().pasted_prompts.is_empty()); + } + + #[tokio::test] + async fn dispatch_gate_starts_free_executor_with_scoped_reservation() { + let (db, _workspace, session) = fixture().await; + let dispatcher_state = Arc::new(StdMutex::new(DispatcherState::default())); + let dispatcher = Arc::new(RecordingDispatcher { + db: db.clone(), + state: dispatcher_state.clone(), + }); + let (service, _) = service_with_components( + db.clone(), + report(false, Some(false), SidEvidence::Unknown), + Arc::new(FakeTransport), + dispatcher, + ); + + let outcome = service + .dispatch_gate( + &session, + "start immediately".to_string(), + executor_config(), + QueuedMessageSource::Ui, + false, + ) + .await + .unwrap(); + let DispatchOutcome::Started { execution_process } = outcome else { + panic!("free lease must start the executor"); + }; + assert_eq!(execution_process.session_id, session.id); + let (prompts, reservation_seen) = { + let state = dispatcher_state.lock().unwrap(); + (state.prompts.clone(), state.reservation_seen) + }; + assert_eq!(prompts, ["start immediately"]); + assert!(reservation_seen); + assert!( + WorkspaceSpawnReservation::find(&db.pool, session.workspace_id) + .await + .unwrap() + .is_none() + ); + assert!( + SessionQueuedMessage::find_active(&db.pool, session.id) + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn replace_contract_preserves_conflict_details_and_rejects_in_flight_replace() { + let (db, _workspace, session) = fixture().await; + let (service, _) = service(db.clone(), report(false, Some(false), SidEvidence::Unknown)); + + let first = service + .queue_only( + &session, + "queued from recovery".to_string(), + executor_config(), + QueuedMessageSource::Recovery, + false, + ) + .await + .unwrap(); + let DispatchOutcome::Queued { status } = first else { + panic!("empty slot must accept the first prompt"); + }; + let first_id = status.message().unwrap().id; + + let conflict = service + .queue_only( + &session, + "new UI prompt".to_string(), + executor_config(), + QueuedMessageSource::Ui, + false, + ) + .await + .unwrap(); + let DispatchOutcome::Conflict { status } = conflict else { + panic!("queued replacement requires explicit confirmation"); + }; + let conflict_message = status.message().unwrap(); + assert_eq!(conflict_message.id, first_id); + assert_eq!(conflict_message.data.message, "queued from recovery"); + assert_eq!(conflict_message.source, QueuedMessageSource::Recovery); + + let replaced = service + .queue_only( + &session, + "new UI prompt".to_string(), + executor_config(), + QueuedMessageSource::Ui, + true, + ) + .await + .unwrap(); + let DispatchOutcome::Queued { status } = replaced else { + panic!("explicit queued replacement must succeed"); + }; + let replaced_message = status.message().unwrap(); + assert_eq!(replaced_message.id, first_id); + assert_eq!(replaced_message.data.message, "new UI prompt"); + assert_eq!(replaced_message.source, QueuedMessageSource::Ui); + + SessionQueuedMessage::claim(&db.pool, first_id, Some("sid")) + .await + .unwrap() + .unwrap(); + assert!( + SessionQueuedMessage::mark_pasted(&db.pool, first_id) + .await + .unwrap() + ); + let in_flight = service + .queue_only( + &session, + "replace pasted".to_string(), + executor_config(), + QueuedMessageSource::Recovery, + true, + ) + .await + .unwrap(); + let DispatchOutcome::Conflict { status } = in_flight else { + panic!("pasted delivery must never be replaced"); + }; + let in_flight_message = status.message().unwrap(); + assert_eq!(in_flight_message.id, first_id); + assert_eq!(in_flight_message.data.message, "new UI prompt"); + assert_eq!(in_flight_message.state, QueuedMessageState::Pasted); + } + + #[tokio::test] + async fn delivery_reconciliation_requeues_expired_paste_without_losing_message() { + let (db, workspace, expired_session) = fixture().await; + let fresh_session = Session::create( &db.pool, - &CreateExecutionProcess { - session_id: session.id, - executor_action: action, - run_reason: ExecutionProcessRunReason::CodingAgent, + &CreateSession { + executor: Some("CLAUDE_CODE".to_string()), + name: None, }, Uuid::new_v4(), - &[], + workspace.id, ) .await .unwrap(); - assert_eq!(service.derive_lease(&session).await, WriterLease::Executor); + let expired = store_message( + &db, + expired_session.id, + "preserve after timeout", + QueuedMessageSource::Recovery, + ) + .await; + SessionQueuedMessage::claim(&db.pool, expired.id, Some("expired-sid")) + .await + .unwrap() + .unwrap(); + SessionQueuedMessage::mark_pasted(&db.pool, expired.id) + .await + .unwrap(); + sqlx::query( + "UPDATE session_queued_messages \ + SET pasted_at = datetime('now', '-31 seconds') WHERE id = ?", + ) + .bind(expired.id) + .execute(&db.pool) + .await + .unwrap(); - db.pool.close().await; - assert_eq!(service.derive_lease(&session).await, WriterLease::Busy); + let fresh = store_message( + &db, + fresh_session.id, + "still awaiting ack", + QueuedMessageSource::Ui, + ) + .await; + SessionQueuedMessage::claim(&db.pool, fresh.id, Some("fresh-sid")) + .await + .unwrap() + .unwrap(); + SessionQueuedMessage::mark_pasted(&db.pool, fresh.id) + .await + .unwrap(); + + let (service, _) = service(db.clone(), report(false, Some(false), SidEvidence::Unknown)); + service.reconcile_delivery_state().await; + + let expired = SessionQueuedMessage::find_by_id(&db.pool, expired.id) + .await + .unwrap() + .unwrap(); + assert_eq!(expired.state, QueuedMessageState::Queued); + assert_eq!(expired.prompt, "preserve after timeout"); + assert_eq!(expired.source, QueuedMessageSource::Recovery); + assert!( + expired + .failure_reason + .as_deref() + .is_some_and(|reason| reason.contains("not acknowledged")) + ); + let fresh = SessionQueuedMessage::find_by_id(&db.pool, fresh.id) + .await + .unwrap() + .unwrap(); + assert_eq!(fresh.state, QueuedMessageState::Pasted); + assert_eq!(fresh.prompt, "still awaiting ack"); + } + + #[tokio::test] + async fn startup_reconciliation_recovers_pasting_and_honors_ack_without_repaste() { + let (db, workspace, interrupted_session) = fixture().await; + let acknowledged_session = Session::create( + &db.pool, + &CreateSession { + executor: Some("CLAUDE_CODE".to_string()), + name: None, + }, + Uuid::new_v4(), + workspace.id, + ) + .await + .unwrap(); + let interrupted = store_message( + &db, + interrupted_session.id, + "recover interrupted paste", + QueuedMessageSource::Recovery, + ) + .await; + SessionQueuedMessage::claim(&db.pool, interrupted.id, Some("interrupted-sid")) + .await + .unwrap() + .unwrap(); + sqlx::query( + "UPDATE session_queued_messages \ + SET updated_at = datetime('now', '-6 seconds') WHERE id = ?", + ) + .bind(interrupted.id) + .execute(&db.pool) + .await + .unwrap(); + + let acknowledged = store_message( + &db, + acknowledged_session.id, + "already imported", + QueuedMessageSource::Ui, + ) + .await; + SessionQueuedMessage::claim(&db.pool, acknowledged.id, Some("acknowledged-sid")) + .await + .unwrap() + .unwrap(); + SessionQueuedMessage::mark_pasted(&db.pool, acknowledged.id) + .await + .unwrap(); + let native_file = CliNativeFile::register( + &db.pool, + &RegisterCliNativeFile { + claude_session_id: "acknowledged-sid", + dir_path: "/tmp/collab-native", + file_name: "acknowledged-sid.jsonl", + discovered_workspace_id: Some(workspace.id), + dev: 1, + inode: 1, + observed_size: 1, + observed_mtime_ms: None, + }, + ) + .await + .unwrap(); + sqlx::query( + "INSERT INTO cli_native_records \ + (file_id, line_seq, claude_session_id, uuid, kind, raw, \ + disposition, bound_queued_message_id) \ + VALUES (?, 0, ?, ?, 'user', '{}', 'renderable', ?)", + ) + .bind(native_file.id) + .bind("acknowledged-sid") + .bind("acknowledged-user") + .bind(acknowledged.id) + .execute(&db.pool) + .await + .unwrap(); + + let transport_state = Arc::new(StdMutex::new(TransportState::default())); + let transport = Arc::new(RecordingTransport { + db: db.clone(), + session_id: acknowledged_session.id, + state: transport_state.clone(), + paste_succeeds: true, + }); + let (service, _) = service_with_components( + db.clone(), + report(false, Some(false), SidEvidence::Unknown), + transport, + Arc::new(NeverDispatcher), + ); + service.reconcile_delivery_state().await; + + let interrupted = SessionQueuedMessage::find_by_id(&db.pool, interrupted.id) + .await + .unwrap() + .unwrap(); + assert_eq!(interrupted.state, QueuedMessageState::Queued); + assert_eq!(interrupted.prompt, "recover interrupted paste"); + let acknowledged = SessionQueuedMessage::find_by_id(&db.pool, acknowledged.id) + .await + .unwrap() + .unwrap(); + assert_eq!(acknowledged.state, QueuedMessageState::Imported); + assert!(acknowledged.acked_at.is_some()); + assert!( + !service + .drain_session(acknowledged_session.id) + .await + .unwrap() + ); + assert!(transport_state.lock().unwrap().pasted_prompts.is_empty()); } } From b85ee68b532f833e66662798235f3febdaecfc95 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 13:23:27 +0000 Subject: [PATCH 08/36] chore(types): regenerate Phase 2 API bindings --- shared/types.ts | 45 +++++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/shared/types.ts b/shared/types.ts index 6ccb524a495..a9b32e28203 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -187,6 +187,25 @@ is_coding_agent_running: boolean, is_errored: boolean, id: string, task_id: stri export type Session = { id: string, workspace_id: string, name: string | null, executor: string | null, agent_working_dir: string | null, created_at: string, updated_at: string, }; +export type QueuedMessageSource = "ui" | "recovery"; + +export type QueuedMessageState = "queued" | "pasting" | "pasted" | "imported" | "failed" | "consumed" | "cancelled"; + +export type SessionQueuedMessage = { id: string, session_id: string, prompt: string, +/** + * Serialized [`ExecutorConfig`]. Paste delivery does not need it, but a + * queued prompt must retain it across a restart before executor dispatch. + */ +executor_config: ExecutorConfig | null, source: QueuedMessageSource, state: QueuedMessageState, failure_reason: string | null, claude_session_id: string | null, pasted_at: string | null, acked_at: string | null, created_at: string, updated_at: string, }; + +export type CliPaneBoundVia = "cli-resume" | "cli-fresh"; + +export type CliPaneBinding = { id: string, workspace_id: string, session_id: string, claude_session_id: string | null, bound_via: CliPaneBoundVia, created_at: string, released_at: string | null, }; + +export type SpawnReservationHolder = "executor" | "cli"; + +export type WorkspaceSpawnReservation = { workspace_id: string, holder: SpawnReservationHolder, fence: string, created_at: string, expires_at: string, }; + export type ExecutionProcess = { id: string, session_id: string, run_reason: ExecutionProcessRunReason, executor_action: ExecutorAction, status: ExecutionProcessStatus, exit_code: bigint | null, /** * dropped: true if this process is excluded from the current @@ -359,7 +378,7 @@ export type NativeFeedFork = { claude_session_id: string, file_id: string, fork: export type NativeFileImportHealth = { claude_session_id: string, file_name: string, generation: bigint, last_import_at: string | null, }; -export type NativeIngestHealth = { unknown_kinds: bigint, rescans: bigint, quarantined_files: bigint, watch_degraded: boolean, files: Array, }; +export type NativeIngestHealth = { unknown_kinds: bigint, rescans: bigint, quarantined_files: bigint, watch_degraded: boolean, foreign_writer_seen_at: string | null, files: Array, }; export type NativeFeedSnapshot = { revision: bigint, seq: bigint, entries: Array, forks: Array, health: NativeIngestHealth, }; @@ -385,7 +404,11 @@ export type RefreshRelaySigningSessionRequest = { client_id: string, timestamp: export type RefreshRelaySigningSessionResponse = { signing_session_id: string, }; -export type CreateFollowUpAttempt = { prompt: string, executor_config: ExecutorConfig, retry_process_id: string | null, force_when_dirty: boolean | null, perform_git_reset: boolean | null, }; +export type CreateFollowUpAttempt = { prompt: string, executor_config: ExecutorConfig, retry_process_id: string | null, force_when_dirty: boolean | null, perform_git_reset: boolean | null, replace: boolean, }; + +export type ForkRecoveryRequest = { fork_parent_uuid: string, branch_leaf_uuid: string, }; + +export type QueueMessageRequest = { message: string, executor_config: ExecutorConfig, replace: boolean, }; export type ResetProcessRequest = { process_id: string, force_when_dirty: boolean | null, perform_git_reset: boolean | null, }; @@ -625,21 +648,11 @@ export type SendMessageShortcut = "ModifierEnter" | "Enter"; export type GitBranch = { name: string, is_current: boolean, is_remote: boolean, last_commit_date: Date, }; -export type QueuedMessage = { -/** - * The session this message is queued for - */ -session_id: string, -/** - * The follow-up data (message + variant) - */ -data: DraftFollowUpData, -/** - * Timestamp when the message was queued - */ -queued_at: string, }; +export type QueuedMessage = { id: string, session_id: string, data: DraftFollowUpData, source: QueuedMessageSource, state: QueuedMessageState, failure_reason: string | null, claude_session_id: string | null, pasted_at: string | null, acked_at: string | null, queued_at: string, updated_at: string, }; + +export type QueueStatus = { "status": "empty" } | { "status": "queued", message: QueuedMessage, } | { "status": "pasting", message: QueuedMessage, } | { "status": "pasted", message: QueuedMessage, }; -export type QueueStatus = { "status": "empty" } | { "status": "queued", message: QueuedMessage, }; +export type DispatchOutcome = { "outcome": "started", execution_process: ExecutionProcess, } | { "outcome": "queued", status: QueueStatus, } | { "outcome": "routed_to_cli", delivery: QueueStatus, } | { "outcome": "conflict", status: QueueStatus, }; export type ConflictOp = "rebase" | "merge" | "cherry_pick" | "revert"; From 0e5ed1f48a7d5e18ea2eb677c2414db4a0623d67 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 13:26:04 +0000 Subject: [PATCH 09/36] fix(lint): clarify terminal launch parameters --- crates/local-deployment/src/pty.rs | 19 ++++++++++--------- crates/server/src/routes/terminal.rs | 10 ++++++---- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/local-deployment/src/pty.rs b/crates/local-deployment/src/pty.rs index 60ad25430e1..d94e68e20ae 100644 --- a/crates/local-deployment/src/pty.rs +++ b/crates/local-deployment/src/pty.rs @@ -2595,6 +2595,15 @@ impl PtyService { // CLI mode rides tmux when present; otherwise (and for the // default side terminal) spawn the user's shell directly. + type CliTmuxCommandParts = ( + Option, + Option, + Option, + bool, + bool, + bool, + Option, + ); let ( tmux_workspace, tmux_resume_id, @@ -2603,15 +2612,7 @@ impl PtyService { tmux_busy_wait, tmux_connect_hidden, tmux_spec, - ): ( - Option, - Option, - Option, - bool, - bool, - bool, - Option, - ) = match &command { + ): CliTmuxCommandParts = match &command { PtyCommand::TmuxCli { workspace_id, resume_session_id, diff --git a/crates/server/src/routes/terminal.rs b/crates/server/src/routes/terminal.rs index 8ef984a77d4..62e858e1e8b 100644 --- a/crates/server/src/routes/terminal.rs +++ b/crates/server/src/routes/terminal.rs @@ -516,8 +516,10 @@ async fn terminal_ws( socket, deployment, working_dir, - query.cols.unwrap_or(DEFAULT_COLS), - query.rows.unwrap_or(DEFAULT_ROWS), + ( + query.cols.unwrap_or(DEFAULT_COLS), + query.rows.unwrap_or(DEFAULT_ROWS), + ), command, prompt_delivery, cli_launch_registration, @@ -563,12 +565,12 @@ async fn handle_terminal_ws( mut socket: MaybeSignedWebSocket, deployment: DeploymentImpl, working_dir: PathBuf, - cols: u16, - rows: u16, + dimensions: (u16, u16), command: PtyCommand, prompt_delivery: Option, cli_launch_registration: Option, ) { + let (cols, rows) = dimensions; // FIX 4 tripwire label: the pty session name, captured before `command` is // moved into `create_session`. For CLI mode this is the actual current // `bc_` or legacy `vk_` name, so the bytes line up with tmux logs. From 57450cd9b8955349abdea18539e3a5ba05396c2c Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 13:30:11 +0000 Subject: [PATCH 10/36] test(server): update native feed health snapshot --- crates/server/src/routes/native_transcripts.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/server/src/routes/native_transcripts.rs b/crates/server/src/routes/native_transcripts.rs index eaca4983dd5..e7d94a4a965 100644 --- a/crates/server/src/routes/native_transcripts.rs +++ b/crates/server/src/routes/native_transcripts.rs @@ -312,6 +312,7 @@ mod tests { "rescans": 0, "quarantined_files": 0, "watch_degraded": false, + "foreign_writer_seen_at": null, "files": [] } } From 5899881e1121eea5d2f7aa651317bbae34c692be Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 13:45:16 +0000 Subject: [PATCH 11/36] fix(db): preserve late CLI paste acknowledgements --- ...a84d85b80ad65dba417b5115dbd02562737bc.json | 12 --- ...4f89be9510689ae8256168ebd4d917f021b14.json | 12 --- ...7980f8d731f1c74804acd21f57b95484d0b7f.json | 12 +++ ...2c7ebe61f6a4976e6004c91e79a1579db117c.json | 12 +++ ...4909ebcf61ae21ae5b845e5ccb4a85ec094cb.json | 12 --- ...f132941a67496ef81bf854c30745a79badcd3.json | 20 +++++ ...3fbf2b665dbffa15a04a45e69a6780a70eb9c.json | 20 ----- ...6395aff01a59d9e9651d5fb968c60f894711c.json | 12 +++ ...7437aa932065d30ccbf572f15535d2fec9790.json | 12 +++ ...7728739ce41269ead752d56d93d08f505dba7.json | 12 --- crates/db/src/models/cli_native_record.rs | 25 +++++- .../db/src/models/session_queued_message.rs | 82 ++++++++++++++++--- .../claude_transcript_ingest/tests.rs | 66 +++++++++++++++ 13 files changed, 226 insertions(+), 83 deletions(-) delete mode 100644 crates/db/.sqlx/query-16f187e3600311ad7502f6034f6a84d85b80ad65dba417b5115dbd02562737bc.json delete mode 100644 crates/db/.sqlx/query-17e0e0cd01625e3dfd9ba9ffc994f89be9510689ae8256168ebd4d917f021b14.json create mode 100644 crates/db/.sqlx/query-2043ad13cca0afa336596d686cf7980f8d731f1c74804acd21f57b95484d0b7f.json create mode 100644 crates/db/.sqlx/query-4edc2e8decf655907efc6a0f7372c7ebe61f6a4976e6004c91e79a1579db117c.json delete mode 100644 crates/db/.sqlx/query-5a48e6d609556ff6fa84a5822da4909ebcf61ae21ae5b845e5ccb4a85ec094cb.json create mode 100644 crates/db/.sqlx/query-712f0c5d265b9181f360f437af4f132941a67496ef81bf854c30745a79badcd3.json delete mode 100644 crates/db/.sqlx/query-9c317d6ecf11ea26e04a8b4f7d63fbf2b665dbffa15a04a45e69a6780a70eb9c.json create mode 100644 crates/db/.sqlx/query-accca376c4344d10a3a078643b46395aff01a59d9e9651d5fb968c60f894711c.json create mode 100644 crates/db/.sqlx/query-b1bf856c4877e2d527959dd02b47437aa932065d30ccbf572f15535d2fec9790.json delete mode 100644 crates/db/.sqlx/query-faf9ba8cfbe810d165d289579c97728739ce41269ead752d56d93d08f505dba7.json diff --git a/crates/db/.sqlx/query-16f187e3600311ad7502f6034f6a84d85b80ad65dba417b5115dbd02562737bc.json b/crates/db/.sqlx/query-16f187e3600311ad7502f6034f6a84d85b80ad65dba417b5115dbd02562737bc.json deleted file mode 100644 index f381c05e41a..00000000000 --- a/crates/db/.sqlx/query-16f187e3600311ad7502f6034f6a84d85b80ad65dba417b5115dbd02562737bc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'imported',\n acked_at = COALESCE(acked_at, datetime('now', 'subsec')),\n updated_at = datetime('now', 'subsec')\n WHERE state IN ('pasting', 'pasted')\n AND EXISTS (\n SELECT 1 FROM cli_native_records record\n WHERE record.bound_queued_message_id = session_queued_messages.id\n )", - "describe": { - "columns": [], - "parameters": { - "Right": 0 - }, - "nullable": [] - }, - "hash": "16f187e3600311ad7502f6034f6a84d85b80ad65dba417b5115dbd02562737bc" -} diff --git a/crates/db/.sqlx/query-17e0e0cd01625e3dfd9ba9ffc994f89be9510689ae8256168ebd4d917f021b14.json b/crates/db/.sqlx/query-17e0e0cd01625e3dfd9ba9ffc994f89be9510689ae8256168ebd4d917f021b14.json deleted file mode 100644 index fcc23df02cf..00000000000 --- a/crates/db/.sqlx/query-17e0e0cd01625e3dfd9ba9ffc994f89be9510689ae8256168ebd4d917f021b14.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'imported',\n claude_session_id = COALESCE(claude_session_id, $1),\n acked_at = $2,\n updated_at = $2\n WHERE id = $3 AND state IN ('pasting', 'pasted')", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "17e0e0cd01625e3dfd9ba9ffc994f89be9510689ae8256168ebd4d917f021b14" -} diff --git a/crates/db/.sqlx/query-2043ad13cca0afa336596d686cf7980f8d731f1c74804acd21f57b95484d0b7f.json b/crates/db/.sqlx/query-2043ad13cca0afa336596d686cf7980f8d731f1c74804acd21f57b95484d0b7f.json new file mode 100644 index 00000000000..ee8c5289fb1 --- /dev/null +++ b/crates/db/.sqlx/query-2043ad13cca0afa336596d686cf7980f8d731f1c74804acd21f57b95484d0b7f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'imported',\n claude_session_id = COALESCE(claude_session_id, $1),\n acked_at = $2,\n updated_at = $2\n WHERE id = $3\n AND (\n state IN ('pasting', 'pasted')\n OR (\n state = 'queued'\n AND failure_reason = $4\n AND pasted_at IS NOT NULL\n )\n )", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "2043ad13cca0afa336596d686cf7980f8d731f1c74804acd21f57b95484d0b7f" +} diff --git a/crates/db/.sqlx/query-4edc2e8decf655907efc6a0f7372c7ebe61f6a4976e6004c91e79a1579db117c.json b/crates/db/.sqlx/query-4edc2e8decf655907efc6a0f7372c7ebe61f6a4976e6004c91e79a1579db117c.json new file mode 100644 index 00000000000..fa6d2d6a4dc --- /dev/null +++ b/crates/db/.sqlx/query-4edc2e8decf655907efc6a0f7372c7ebe61f6a4976e6004c91e79a1579db117c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'imported',\n acked_at = COALESCE(acked_at, datetime('now', 'subsec')),\n updated_at = datetime('now', 'subsec')\n WHERE (\n state IN ('pasting', 'pasted')\n OR (\n state = 'queued'\n AND failure_reason = $1\n AND pasted_at IS NOT NULL\n )\n )\n AND EXISTS (\n SELECT 1 FROM cli_native_records record\n WHERE record.bound_queued_message_id = session_queued_messages.id\n )", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "4edc2e8decf655907efc6a0f7372c7ebe61f6a4976e6004c91e79a1579db117c" +} diff --git a/crates/db/.sqlx/query-5a48e6d609556ff6fa84a5822da4909ebcf61ae21ae5b845e5ccb4a85ec094cb.json b/crates/db/.sqlx/query-5a48e6d609556ff6fa84a5822da4909ebcf61ae21ae5b845e5ccb4a85ec094cb.json deleted file mode 100644 index f84d2237068..00000000000 --- a/crates/db/.sqlx/query-5a48e6d609556ff6fa84a5822da4909ebcf61ae21ae5b845e5ccb4a85ec094cb.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = $1,\n claude_session_id = NULL,\n pasted_at = NULL,\n acked_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $2 AND state IN ('pasting', 'pasted')", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "5a48e6d609556ff6fa84a5822da4909ebcf61ae21ae5b845e5ccb4a85ec094cb" -} diff --git a/crates/db/.sqlx/query-712f0c5d265b9181f360f437af4f132941a67496ef81bf854c30745a79badcd3.json b/crates/db/.sqlx/query-712f0c5d265b9181f360f437af4f132941a67496ef81bf854c30745a79badcd3.json new file mode 100644 index 00000000000..30b57d7b1cf --- /dev/null +++ b/crates/db/.sqlx/query-712f0c5d265b9181f360f437af4f132941a67496ef81bf854c30745a79badcd3.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT id AS \"id!: Uuid\"\n FROM session_queued_messages\n WHERE session_id = $1\n AND (\n state IN ('pasting', 'pasted')\n OR (\n state = 'queued'\n AND failure_reason = $6\n )\n )\n AND prompt = $2\n AND (claude_session_id IS NULL OR claude_session_id = $3)\n AND pasted_at IS NOT NULL\n AND julianday(pasted_at) >= julianday($4)\n AND julianday(pasted_at) <= julianday($5)\n ORDER BY pasted_at DESC\n LIMIT 1", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + } + ], + "parameters": { + "Right": 6 + }, + "nullable": [ + true + ] + }, + "hash": "712f0c5d265b9181f360f437af4f132941a67496ef81bf854c30745a79badcd3" +} diff --git a/crates/db/.sqlx/query-9c317d6ecf11ea26e04a8b4f7d63fbf2b665dbffa15a04a45e69a6780a70eb9c.json b/crates/db/.sqlx/query-9c317d6ecf11ea26e04a8b4f7d63fbf2b665dbffa15a04a45e69a6780a70eb9c.json deleted file mode 100644 index 7363b7d7e1a..00000000000 --- a/crates/db/.sqlx/query-9c317d6ecf11ea26e04a8b4f7d63fbf2b665dbffa15a04a45e69a6780a70eb9c.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT id AS \"id!: Uuid\"\n FROM session_queued_messages\n WHERE session_id = $1\n AND state IN ('pasting', 'pasted')\n AND prompt = $2\n AND (claude_session_id IS NULL OR claude_session_id = $3)\n AND pasted_at IS NOT NULL\n AND julianday(pasted_at) >= julianday($4)\n AND julianday(pasted_at) <= julianday($5)\n ORDER BY pasted_at DESC\n LIMIT 1", - "describe": { - "columns": [ - { - "name": "id!: Uuid", - "ordinal": 0, - "type_info": "Blob" - } - ], - "parameters": { - "Right": 5 - }, - "nullable": [ - true - ] - }, - "hash": "9c317d6ecf11ea26e04a8b4f7d63fbf2b665dbffa15a04a45e69a6780a70eb9c" -} diff --git a/crates/db/.sqlx/query-accca376c4344d10a3a078643b46395aff01a59d9e9651d5fb968c60f894711c.json b/crates/db/.sqlx/query-accca376c4344d10a3a078643b46395aff01a59d9e9651d5fb968c60f894711c.json new file mode 100644 index 00000000000..e2780519abc --- /dev/null +++ b/crates/db/.sqlx/query-accca376c4344d10a3a078643b46395aff01a59d9e9651d5fb968c60f894711c.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = $1,\n claude_session_id = CASE\n WHEN state = 'pasted' THEN claude_session_id\n ELSE NULL\n END,\n pasted_at = CASE\n WHEN state = 'pasted' THEN pasted_at\n ELSE NULL\n END,\n acked_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $2 AND state IN ('pasting', 'pasted')", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "accca376c4344d10a3a078643b46395aff01a59d9e9651d5fb968c60f894711c" +} diff --git a/crates/db/.sqlx/query-b1bf856c4877e2d527959dd02b47437aa932065d30ccbf572f15535d2fec9790.json b/crates/db/.sqlx/query-b1bf856c4877e2d527959dd02b47437aa932065d30ccbf572f15535d2fec9790.json new file mode 100644 index 00000000000..702966f289a --- /dev/null +++ b/crates/db/.sqlx/query-b1bf856c4877e2d527959dd02b47437aa932065d30ccbf572f15535d2fec9790.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = $2,\n updated_at = datetime('now', 'subsec')\n WHERE state = 'pasted'\n AND julianday(pasted_at) <= julianday($1)", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "b1bf856c4877e2d527959dd02b47437aa932065d30ccbf572f15535d2fec9790" +} diff --git a/crates/db/.sqlx/query-faf9ba8cfbe810d165d289579c97728739ce41269ead752d56d93d08f505dba7.json b/crates/db/.sqlx/query-faf9ba8cfbe810d165d289579c97728739ce41269ead752d56d93d08f505dba7.json deleted file mode 100644 index 9d535646f6a..00000000000 --- a/crates/db/.sqlx/query-faf9ba8cfbe810d165d289579c97728739ce41269ead752d56d93d08f505dba7.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = 'CLI submission was not acknowledged; queued for retry',\n claude_session_id = NULL,\n pasted_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE state = 'pasted'\n AND julianday(pasted_at) <= julianday($1)", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "faf9ba8cfbe810d165d289579c97728739ce41269ead752d56d93d08f505dba7" -} diff --git a/crates/db/src/models/cli_native_record.rs b/crates/db/src/models/cli_native_record.rs index c5952147bc5..1d95ddbfa83 100644 --- a/crates/db/src/models/cli_native_record.rs +++ b/crates/db/src/models/cli_native_record.rs @@ -6,6 +6,7 @@ use uuid::Uuid; use super::{ cli_ingest_outbox::CliIngestOutbox, cli_native_file::{CliNativeFile, RegisterCliNativeFile}, + session_queued_message::PASTED_REQUEUE_FAILURE_REASON, }; #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] @@ -490,7 +491,13 @@ impl CliNativeRecord { r#"SELECT id AS "id!: Uuid" FROM session_queued_messages WHERE session_id = $1 - AND state IN ('pasting', 'pasted') + AND ( + state IN ('pasting', 'pasted') + OR ( + state = 'queued' + AND failure_reason = $6 + ) + ) AND prompt = $2 AND (claude_session_id IS NULL OR claude_session_id = $3) AND pasted_at IS NOT NULL @@ -502,7 +509,8 @@ impl CliNativeRecord { prompt, record.claude_session_id, earliest_paste, - latest_paste + latest_paste, + PASTED_REQUEUE_FAILURE_REASON ) .fetch_optional(&mut **tx) .await? @@ -547,10 +555,19 @@ impl CliNativeRecord { claude_session_id = COALESCE(claude_session_id, $1), acked_at = $2, updated_at = $2 - WHERE id = $3 AND state IN ('pasting', 'pasted')"#, + WHERE id = $3 + AND ( + state IN ('pasting', 'pasted') + OR ( + state = 'queued' + AND failure_reason = $4 + AND pasted_at IS NOT NULL + ) + )"#, record.claude_session_id, imported_at, - queue_id + queue_id, + PASTED_REQUEUE_FAILURE_REASON ) .execute(&mut **tx) .await?; diff --git a/crates/db/src/models/session_queued_message.rs b/crates/db/src/models/session_queued_message.rs index 78670c6aa7e..1f679ebeb81 100644 --- a/crates/db/src/models/session_queued_message.rs +++ b/crates/db/src/models/session_queued_message.rs @@ -5,6 +5,9 @@ use sqlx::{FromRow, SqlitePool, Type}; use ts_rs::TS; use uuid::Uuid; +pub const PASTED_REQUEUE_FAILURE_REASON: &str = + "CLI submission was not acknowledged; queued for retry"; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)] #[sqlx(type_name = "TEXT", rename_all = "lowercase")] #[serde(rename_all = "lowercase")] @@ -85,6 +88,12 @@ impl SessionQueuedMessage { .transpose() } + pub fn was_requeued_from_pasted(&self) -> bool { + self.state == QueuedMessageState::Queued + && self.pasted_at.is_some() + && self.failure_reason.as_deref() == Some(PASTED_REQUEUE_FAILURE_REASON) + } + pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { let sql = format!( "SELECT {} FROM session_queued_messages WHERE id = ?", @@ -239,8 +248,14 @@ impl SessionQueuedMessage { r#"UPDATE session_queued_messages SET state = 'queued', failure_reason = $1, - claude_session_id = NULL, - pasted_at = NULL, + claude_session_id = CASE + WHEN state = 'pasted' THEN claude_session_id + ELSE NULL + END, + pasted_at = CASE + WHEN state = 'pasted' THEN pasted_at + ELSE NULL + END, acked_at = NULL, updated_at = datetime('now', 'subsec') WHERE id = $2 AND state IN ('pasting', 'pasted')"#, @@ -252,6 +267,13 @@ impl SessionQueuedMessage { Ok(result.rows_affected() > 0) } + /// Return a paste with missing delivery evidence to the persisted slot. + /// The original timestamp and sid stay attached so a delayed native user + /// record can still acknowledge this exact delivery. + pub async fn requeue_pasted(pool: &SqlitePool, id: Uuid) -> Result { + Self::requeue(pool, id, Some(PASTED_REQUEUE_FAILURE_REASON)).await + } + pub async fn set_failure_reason( pool: &SqlitePool, id: Uuid, @@ -323,11 +345,19 @@ impl SessionQueuedMessage { state = 'imported', acked_at = COALESCE(acked_at, datetime('now', 'subsec')), updated_at = datetime('now', 'subsec') - WHERE state IN ('pasting', 'pasted') + WHERE ( + state IN ('pasting', 'pasted') + OR ( + state = 'queued' + AND failure_reason = $1 + AND pasted_at IS NOT NULL + ) + ) AND EXISTS ( SELECT 1 FROM cli_native_records record WHERE record.bound_queued_message_id = session_queued_messages.id - )"# + )"#, + PASTED_REQUEUE_FAILURE_REASON ) .execute(pool) .await? @@ -353,13 +383,12 @@ impl SessionQueuedMessage { let requeued_pasted = sqlx::query!( r#"UPDATE session_queued_messages SET state = 'queued', - failure_reason = 'CLI submission was not acknowledged; queued for retry', - claude_session_id = NULL, - pasted_at = NULL, + failure_reason = $2, updated_at = datetime('now', 'subsec') WHERE state = 'pasted' AND julianday(pasted_at) <= julianday($1)"#, - pasted_cutoff + pasted_cutoff, + PASTED_REQUEUE_FAILURE_REASON ) .execute(pool) .await? @@ -489,7 +518,7 @@ mod tests { } #[tokio::test] - async fn delivery_reconciliation_requeues_interrupted_and_unacked_rows() { + async fn delivery_reconciliation_recovers_pasting_but_only_hard_caps_pasted_rows() { let (pool, session_id) = session_fixture().await; let row = match SessionQueuedMessage::store( &pool, @@ -550,7 +579,32 @@ mod tests { &pool, Utc::now(), Duration::seconds(5), - Duration::seconds(30), + Duration::minutes(15), + ) + .await + .unwrap(); + assert_eq!(recovered.requeued_pasted, 0); + assert_eq!( + SessionQueuedMessage::find_by_id(&pool, row.id) + .await + .unwrap() + .unwrap() + .state, + QueuedMessageState::Pasted + ); + + let hard_capped_at = Utc::now() - Duration::minutes(16); + sqlx::query("UPDATE session_queued_messages SET pasted_at = ? WHERE id = ?") + .bind(hard_capped_at) + .bind(row.id) + .execute(&pool) + .await + .unwrap(); + let recovered = SessionQueuedMessage::reconcile( + &pool, + Utc::now(), + Duration::seconds(5), + Duration::minutes(15), ) .await .unwrap(); @@ -560,6 +614,12 @@ mod tests { .unwrap() .unwrap(); assert_eq!(active.state, QueuedMessageState::Queued); - assert!(active.failure_reason.unwrap().contains("not acknowledged")); + assert!(active.was_requeued_from_pasted()); + assert_eq!(active.pasted_at, Some(hard_capped_at)); + assert_eq!(active.claude_session_id.as_deref(), Some("sid")); + assert_eq!( + active.failure_reason.as_deref(), + Some(PASTED_REQUEUE_FAILURE_REASON) + ); } } diff --git a/crates/services/src/services/claude_transcript_ingest/tests.rs b/crates/services/src/services/claude_transcript_ingest/tests.rs index 72ce6aed086..39683fb12ad 100644 --- a/crates/services/src/services/claude_transcript_ingest/tests.rs +++ b/crates/services/src/services/claude_transcript_ingest/tests.rs @@ -579,6 +579,72 @@ async fn paste_ack_binding_and_slot_import_are_atomic_and_project_as_app_origin( assert_eq!(entry.origin, NativeFeedOrigin::App); } +#[tokio::test] +async fn late_paste_ack_imports_requeued_slot_and_blocks_duplicate_claim() { + let temp = TempDir::new().unwrap(); + let db = test_db().await; + let (workspace, session) = create_workspace_and_session(&db, temp.path()).await; + let sid = "45454545-4545-4545-8545-454545454545"; + let cwd = effective_cwd(&workspace, &session).unwrap(); + ClaudeSessionLink::assign_manual(&db.pool, sid, session.id, &cwd.to_string_lossy()) + .await + .unwrap() + .unwrap(); + let native_file = register_native_file(&db, workspace.id, sid).await; + let slot = pasted_slot(&db, session.id, sid, "late CLI submission").await; + let pasted_at = slot.pasted_at.unwrap(); + + assert!( + SessionQueuedMessage::requeue_pasted(&db.pool, slot.id) + .await + .unwrap() + ); + let requeued = SessionQueuedMessage::find_by_id(&db.pool, slot.id) + .await + .unwrap() + .unwrap(); + assert!(requeued.was_requeued_from_pasted()); + assert_eq!(requeued.pasted_at, Some(pasted_at)); + + CliNativeRecord::import_batch( + &db.pool, + native_file.id, + &[import_record( + sid, + 0, + "late-delivery-bound-user", + "late CLI submission", + pasted_at + ChronoDuration::seconds(31), + )], + &cursor(1), + ) + .await + .unwrap(); + + let rows = CliNativeRecord::list_for_session(&db.pool, session.id) + .await + .unwrap(); + assert_eq!(rows[0].bound_queued_message_id, Some(slot.id)); + let imported = SessionQueuedMessage::find_by_id(&db.pool, slot.id) + .await + .unwrap() + .unwrap(); + assert_eq!(imported.state, QueuedMessageState::Imported); + assert!(imported.acked_at.is_some()); + assert!( + SessionQueuedMessage::claim(&db.pool, slot.id, None) + .await + .unwrap() + .is_none() + ); + assert!( + SessionQueuedMessage::find_active(&db.pool, session.id) + .await + .unwrap() + .is_none() + ); +} + #[tokio::test] async fn paste_ack_matcher_enforces_paste_window_bounds() { let temp = TempDir::new().unwrap(); From 86402187f0a69d252d0367e2cfa54ec9c2a3242f Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 13:45:53 +0000 Subject: [PATCH 12/36] fix(services): condition paste retries on liveness --- crates/services/src/services/cli_collab.rs | 392 ++++++++++++++++++--- 1 file changed, 336 insertions(+), 56 deletions(-) diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index 00af71b9b02..c07a9499146 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -38,7 +38,7 @@ use super::{ const DRAIN_INTERVAL: Duration = Duration::from_secs(1); const PASTING_STARTUP_GRACE: ChronoDuration = ChronoDuration::seconds(5); -const PASTE_ACK_TIMEOUT: ChronoDuration = ChronoDuration::seconds(30); +const PASTE_ACK_HARD_CAP: ChronoDuration = ChronoDuration::minutes(15); #[derive(Debug, Clone, PartialEq, Eq)] pub enum SidEvidence { @@ -691,6 +691,9 @@ impl CliCollabService { match self.derive_lease_locked(&session).await { WriterLease::Executor | WriterLease::CliAmbiguous | WriterLease::Busy => Ok(false), WriterLease::Cli { claude_session_id } => { + if self.original_paste_binding_is_active(&row).await? { + return Ok(false); + } let routed = self .paste_slot(&session, row, claude_session_id.as_deref()) .await?; @@ -713,29 +716,131 @@ impl CliCollabService { } } + async fn original_paste_binding_is_active( + &self, + row: &SessionQueuedMessage, + ) -> Result { + if !row.was_requeued_from_pasted() { + return Ok(false); + } + let Some(pasted_at) = row.pasted_at else { + return Ok(false); + }; + Ok( + CliPaneBinding::find_active_for_session(&self.db.pool, row.session_id) + .await? + .is_some_and(|binding| binding.created_at <= pasted_at), + ) + } + + async fn reconcile_pasted_delivery( + &self, + row: SessionQueuedMessage, + ) -> Result { + let lock = self.session_lock(row.session_id).await; + let _guard = lock.lock().await; + let Some(row) = SessionQueuedMessage::find_by_id(&self.db.pool, row.id).await? else { + return Ok(false); + }; + if row.state != QueuedMessageState::Pasted { + return Ok(false); + } + let session = Session::find_by_id(&self.db.pool, row.session_id) + .await? + .ok_or(CliCollabError::WorkspaceMissing(row.session_id))?; + let binding = + CliPaneBinding::find_active_for_session(&self.db.pool, row.session_id).await?; + let Some(binding) = binding else { + return Ok(SessionQueuedMessage::requeue_pasted(&self.db.pool, row.id).await?); + }; + if row + .pasted_at + .is_some_and(|pasted_at| binding.created_at > pasted_at) + { + return Ok(SessionQueuedMessage::requeue_pasted(&self.db.pool, row.id).await?); + } + let workspace = Workspace::find_by_id(&self.db.pool, session.workspace_id) + .await? + .ok_or(CliCollabError::WorkspaceMissing(row.session_id))?; + let Some(effective_dir) = workspace + .container_ref + .as_deref() + .and_then(|root| session.effective_working_dir(Path::new(root))) + else { + return Ok(false); + }; + let report = self + .probe + .probe( + session.workspace_id, + &effective_dir, + row.claude_session_id.as_deref(), + ) + .await; + if report.probe_failed || report.agent_running.is_none() { + return Ok(false); + } + if !report.pane_session_exists { + CliPaneBinding::release(&self.db.pool, binding.id).await?; + } + if !report.pane_session_exists || report.agent_running == Some(false) { + return Ok(SessionQueuedMessage::requeue_pasted(&self.db.pool, row.id).await?); + } + Ok(false) + } + async fn reconcile_delivery_state(&self) { - match SessionQueuedMessage::reconcile( + let reconciled = match SessionQueuedMessage::reconcile( &self.db.pool, Utc::now(), PASTING_STARTUP_GRACE, - PASTE_ACK_TIMEOUT, + PASTE_ACK_HARD_CAP, ) .await { - Ok(reconciled) - if reconciled.imported > 0 - || reconciled.requeued_pasting > 0 - || reconciled.requeued_pasted > 0 => - { - tracing::info!( - imported = reconciled.imported, - requeued_pasting = reconciled.requeued_pasting, - requeued_pasted = reconciled.requeued_pasted, - "reconciled durable CLI delivery state" - ); + Ok(reconciled) => reconciled, + Err(error) => { + tracing::warn!(?error, "failed to reconcile CLI delivery state"); + return; } - Ok(_) => {} - Err(error) => tracing::warn!(?error, "failed to reconcile CLI delivery state"), + }; + let rows = match SessionQueuedMessage::list_active(&self.db.pool).await { + Ok(rows) => rows, + Err(error) => { + tracing::warn!(?error, "failed to scan pasted CLI delivery evidence"); + return; + } + }; + let mut requeued_lost_evidence = 0_u64; + for row in rows + .into_iter() + .filter(|row| row.state == QueuedMessageState::Pasted) + { + let queue_id = row.id; + let session_id = row.session_id; + match self.reconcile_pasted_delivery(row).await { + Ok(true) => requeued_lost_evidence += 1, + Ok(false) => {} + Err(error) => tracing::warn!( + ?error, + %queue_id, + %session_id, + "failed closed while reconciling CLI delivery evidence" + ), + } + } + if reconciled.imported > 0 + || reconciled.requeued_pasting > 0 + || reconciled.requeued_pasted > 0 + || requeued_lost_evidence > 0 + { + tracing::info!( + imported = reconciled.imported, + requeued_pasting = reconciled.requeued_pasting, + requeued_pasted = reconciled.requeued_pasted, + requeued_lost_evidence, + "reconciled durable CLI delivery state" + ); } } @@ -1104,8 +1209,8 @@ mod tests { workspace: &Workspace, session: &Session, sid: &str, - ) { - CliPaneBinding::record_launch( + ) -> CliPaneBinding { + let binding = CliPaneBinding::record_launch( &db.pool, workspace.id, session.id, @@ -1118,6 +1223,7 @@ mod tests { .await .unwrap() .unwrap(); + binding } #[tokio::test] @@ -1435,79 +1541,253 @@ mod tests { } #[tokio::test] - async fn delivery_reconciliation_requeues_expired_paste_without_losing_message() { - let (db, workspace, expired_session) = fixture().await; - let fresh_session = Session::create( - &db.pool, - &CreateSession { - executor: Some("CLAUDE_CODE".to_string()), - name: None, - }, - Uuid::new_v4(), - workspace.id, - ) - .await - .unwrap(); - let expired = store_message( + async fn delivery_reconciliation_keeps_long_live_paste_without_repasting() { + let (db, workspace, session) = fixture().await; + let sid = "31313131-3131-4131-8131-313131313131"; + let binding = bind_confirmed_cli(&db, &workspace, &session, sid).await; + let row = store_message( &db, - expired_session.id, - "preserve after timeout", - QueuedMessageSource::Recovery, + session.id, + "wait through long CLI turn", + QueuedMessageSource::Ui, ) .await; - SessionQueuedMessage::claim(&db.pool, expired.id, Some("expired-sid")) + SessionQueuedMessage::claim(&db.pool, row.id, Some(sid)) .await .unwrap() .unwrap(); - SessionQueuedMessage::mark_pasted(&db.pool, expired.id) + SessionQueuedMessage::mark_pasted(&db.pool, row.id) .await .unwrap(); + sqlx::query( + "UPDATE cli_pane_bindings \ + SET created_at = datetime('now', '-32 seconds') WHERE id = ?", + ) + .bind(binding.id) + .execute(&db.pool) + .await + .unwrap(); sqlx::query( "UPDATE session_queued_messages \ SET pasted_at = datetime('now', '-31 seconds') WHERE id = ?", ) - .bind(expired.id) + .bind(row.id) .execute(&db.pool) .await .unwrap(); + let transport_state = Arc::new(StdMutex::new(TransportState::default())); + let transport = Arc::new(RecordingTransport { + db: db.clone(), + session_id: session.id, + state: transport_state.clone(), + paste_succeeds: true, + }); + let (service, _) = service_with_components( + db.clone(), + report( + true, + Some(true), + SidEvidence::ConfirmedResume(sid.to_string()), + ), + transport, + Arc::new(NeverDispatcher), + ); + + service.reconcile_delivery_state().await; - let fresh = store_message( + let row = SessionQueuedMessage::find_by_id(&db.pool, row.id) + .await + .unwrap() + .unwrap(); + assert_eq!(row.state, QueuedMessageState::Pasted); + assert_eq!(row.prompt, "wait through long CLI turn"); + assert!(row.failure_reason.is_none()); + assert!(!service.drain_session(session.id).await.unwrap()); + assert!(transport_state.lock().unwrap().pasted_prompts.is_empty()); + } + + #[tokio::test] + async fn delivery_reconciliation_requeues_dead_pane_with_notice() { + let (db, workspace, session) = fixture().await; + let sid = "32323232-3232-4232-8232-323232323232"; + let binding = bind_confirmed_cli(&db, &workspace, &session, sid).await; + let row = store_message( &db, - fresh_session.id, - "still awaiting ack", - QueuedMessageSource::Ui, + session.id, + "recover after pane death", + QueuedMessageSource::Recovery, ) .await; - SessionQueuedMessage::claim(&db.pool, fresh.id, Some("fresh-sid")) + SessionQueuedMessage::claim(&db.pool, row.id, Some(sid)) .await .unwrap() .unwrap(); - SessionQueuedMessage::mark_pasted(&db.pool, fresh.id) + SessionQueuedMessage::mark_pasted(&db.pool, row.id) .await .unwrap(); - + let pasted_at = SessionQueuedMessage::find_by_id(&db.pool, row.id) + .await + .unwrap() + .unwrap() + .pasted_at; let (service, _) = service(db.clone(), report(false, Some(false), SidEvidence::Unknown)); + service.reconcile_delivery_state().await; - let expired = SessionQueuedMessage::find_by_id(&db.pool, expired.id) + let row = SessionQueuedMessage::find_by_id(&db.pool, row.id) .await .unwrap() .unwrap(); - assert_eq!(expired.state, QueuedMessageState::Queued); - assert_eq!(expired.prompt, "preserve after timeout"); - assert_eq!(expired.source, QueuedMessageSource::Recovery); + assert!(row.was_requeued_from_pasted()); + assert_eq!(row.pasted_at, pasted_at); + assert_eq!( + row.failure_reason.as_deref(), + Some(db::models::session_queued_message::PASTED_REQUEUE_FAILURE_REASON) + ); assert!( - expired - .failure_reason - .as_deref() - .is_some_and(|reason| reason.contains("not acknowledged")) + CliPaneBinding::find_by_id(&db.pool, binding.id) + .await + .unwrap() + .unwrap() + .released_at + .is_some() + ); + } + + #[tokio::test] + async fn delivery_reconciliation_hard_cap_requeues_with_notice() { + let (db, workspace, session) = fixture().await; + let sid = "33333333-3333-4333-8333-333333333333"; + let binding = bind_confirmed_cli(&db, &workspace, &session, sid).await; + let row = store_message( + &db, + session.id, + "hard capped CLI delivery", + QueuedMessageSource::Ui, + ) + .await; + SessionQueuedMessage::claim(&db.pool, row.id, Some(sid)) + .await + .unwrap() + .unwrap(); + SessionQueuedMessage::mark_pasted(&db.pool, row.id) + .await + .unwrap(); + sqlx::query( + "UPDATE cli_pane_bindings \ + SET created_at = datetime('now', '-17 minutes') WHERE id = ?", + ) + .bind(binding.id) + .execute(&db.pool) + .await + .unwrap(); + sqlx::query( + "UPDATE session_queued_messages \ + SET pasted_at = datetime('now', '-16 minutes') WHERE id = ?", + ) + .bind(row.id) + .execute(&db.pool) + .await + .unwrap(); + let transport_state = Arc::new(StdMutex::new(TransportState::default())); + let transport = Arc::new(RecordingTransport { + db: db.clone(), + session_id: session.id, + state: transport_state.clone(), + paste_succeeds: true, + }); + let (service, _) = service_with_components( + db.clone(), + report( + true, + Some(true), + SidEvidence::ConfirmedResume(sid.to_string()), + ), + transport, + Arc::new(NeverDispatcher), + ); + + service.reconcile_delivery_state().await; + + let row = SessionQueuedMessage::find_by_id(&db.pool, row.id) + .await + .unwrap() + .unwrap(); + assert!(row.was_requeued_from_pasted()); + assert_eq!( + row.failure_reason.as_deref(), + Some(db::models::session_queued_message::PASTED_REQUEUE_FAILURE_REASON) ); - let fresh = SessionQueuedMessage::find_by_id(&db.pool, fresh.id) + assert!(!service.drain_session(session.id).await.unwrap()); + assert!(transport_state.lock().unwrap().pasted_prompts.is_empty()); + } + + #[tokio::test] + async fn requeued_paste_waits_for_original_binding_then_drains_after_release() { + let (db, workspace, session) = fixture().await; + let sid = "34343434-3434-4434-8434-343434343434"; + let binding = bind_confirmed_cli(&db, &workspace, &session, sid).await; + let row = store_message( + &db, + session.id, + "dispatch only after pane release", + QueuedMessageSource::Ui, + ) + .await; + SessionQueuedMessage::claim(&db.pool, row.id, Some(sid)) .await .unwrap() .unwrap(); - assert_eq!(fresh.state, QueuedMessageState::Pasted); - assert_eq!(fresh.prompt, "still awaiting ack"); + SessionQueuedMessage::mark_pasted(&db.pool, row.id) + .await + .unwrap(); + SessionQueuedMessage::requeue_pasted(&db.pool, row.id) + .await + .unwrap(); + let transport_state = Arc::new(StdMutex::new(TransportState::default())); + let transport = Arc::new(RecordingTransport { + db: db.clone(), + session_id: session.id, + state: transport_state.clone(), + paste_succeeds: true, + }); + let dispatcher_state = Arc::new(StdMutex::new(DispatcherState::default())); + let dispatcher = Arc::new(RecordingDispatcher { + db: db.clone(), + state: dispatcher_state.clone(), + }); + let (service, probe) = service_with_components( + db.clone(), + report( + true, + Some(true), + SidEvidence::ConfirmedResume(sid.to_string()), + ), + transport, + dispatcher, + ); + + assert!(!service.drain_session(session.id).await.unwrap()); + assert!(transport_state.lock().unwrap().pasted_prompts.is_empty()); + assert!(dispatcher_state.lock().unwrap().prompts.is_empty()); + + CliPaneBinding::release(&db.pool, binding.id).await.unwrap(); + *probe.0.lock().unwrap() = report(false, Some(false), SidEvidence::Unknown); + assert!(service.drain_session(session.id).await.unwrap()); + + assert!(transport_state.lock().unwrap().pasted_prompts.is_empty()); + assert_eq!( + dispatcher_state.lock().unwrap().prompts, + ["dispatch only after pane release"] + ); + assert_eq!( + SessionQueuedMessage::find_by_id(&db.pool, row.id) + .await + .unwrap() + .unwrap() + .state, + QueuedMessageState::Consumed + ); } #[tokio::test] From 6cf5c4c0d836fe02c257701189abe4100b2f057e Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 13:46:51 +0000 Subject: [PATCH 13/36] test(services): cover dispatcher failure cleanup --- crates/services/src/services/cli_collab.rs | 77 ++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index c07a9499146..090e3fd44f4 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -1064,6 +1064,30 @@ mod tests { } } + struct FailingDispatcher { + db: DBService, + state: Arc>, + } + + #[async_trait] + impl CliExecutorDispatcher for FailingDispatcher { + async fn dispatch( + &self, + session: &Session, + prompt: &str, + _executor_config: &ExecutorConfig, + ) -> AnyhowResult { + let reservation_seen = + WorkspaceSpawnReservation::find(&self.db.pool, session.workspace_id) + .await? + .is_some(); + let mut state = self.state.lock().unwrap(); + state.prompts.push(prompt.to_string()); + state.reservation_seen = reservation_seen; + Err(anyhow::anyhow!("intentional dispatcher failure")) + } + } + async fn fixture() -> (DBService, Workspace, Session) { let pool = SqlitePoolOptions::new() .max_connections(1) @@ -1456,6 +1480,59 @@ mod tests { ); } + #[tokio::test] + async fn dispatcher_error_releases_reservation_and_requeues_prompt() { + let (db, _workspace, session) = fixture().await; + let dispatcher_state = Arc::new(StdMutex::new(DispatcherState::default())); + let dispatcher = Arc::new(FailingDispatcher { + db: db.clone(), + state: dispatcher_state.clone(), + }); + let (service, _) = service_with_components( + db.clone(), + report(false, Some(false), SidEvidence::Unknown), + Arc::new(FakeTransport), + dispatcher, + ); + + let outcome = service + .dispatch_gate( + &session, + "preserve failed dispatch".to_string(), + executor_config(), + QueuedMessageSource::Ui, + false, + ) + .await + .unwrap(); + + let DispatchOutcome::Queued { status } = outcome else { + panic!("dispatcher failure must return the prompt to the queue"); + }; + assert_eq!( + status.message().unwrap().data.message, + "preserve failed dispatch" + ); + let (prompts, reservation_seen) = { + let state = dispatcher_state.lock().unwrap(); + (state.prompts.clone(), state.reservation_seen) + }; + assert_eq!(prompts, ["preserve failed dispatch"]); + assert!(reservation_seen); + assert!( + WorkspaceSpawnReservation::find(&db.pool, session.workspace_id) + .await + .unwrap() + .is_none() + ); + let queued = SessionQueuedMessage::find_active(&db.pool, session.id) + .await + .unwrap() + .unwrap(); + assert_eq!(queued.state, QueuedMessageState::Queued); + assert_eq!(queued.prompt, "preserve failed dispatch"); + } + #[tokio::test] async fn replace_contract_preserves_conflict_details_and_rejects_in_flight_replace() { let (db, _workspace, session) = fixture().await; From 41c9ee24b1ae522a333dc0e62198aea24b978912 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 18:32:35 +0000 Subject: [PATCH 14/36] feat(web): surface dispatch and queue delivery states --- packages/ui/src/components/SessionChatBox.tsx | 603 ++++++++++-------- packages/ui/src/components/Toast.tsx | 71 +++ .../model/collaborationUiState.test.ts | 164 +++++ .../model/collaborationUiState.ts | 106 +++ .../model/hooks/useCreateSession.ts | 15 +- .../model/hooks/useMessageEditRetry.ts | 1 + .../model/hooks/useSessionQueueInteraction.ts | 142 +++-- .../model/hooks/useSessionSend.ts | 52 +- .../ui/SessionChatBoxContainer.tsx | 228 ++++++- .../web-core/src/i18n/locales/en/tasks.json | 24 +- .../dialogs/tasks/ResolveConflictsDialog.tsx | 1 + .../src/shared/hooks/useRetryProcess.ts | 1 + .../src/shared/hooks/useTransientToast.ts | 34 + packages/web-core/src/shared/lib/api.ts | 61 +- 14 files changed, 1159 insertions(+), 344 deletions(-) create mode 100644 packages/ui/src/components/Toast.tsx create mode 100644 packages/web-core/src/features/workspace-chat/model/collaborationUiState.test.ts create mode 100644 packages/web-core/src/features/workspace-chat/model/collaborationUiState.ts create mode 100644 packages/web-core/src/shared/hooks/useTransientToast.ts diff --git a/packages/ui/src/components/SessionChatBox.tsx b/packages/ui/src/components/SessionChatBox.tsx index 11ff965577b..8786403c08c 100644 --- a/packages/ui/src/components/SessionChatBox.tsx +++ b/packages/ui/src/components/SessionChatBox.tsx @@ -37,6 +37,8 @@ import { TurnNavigationPopup, type TurnNavigationItem, } from './TurnNavigationPopup'; +import { Badge } from './Badge'; +import { Toast, type ToastTone } from './Toast'; // Status enum - single source of truth for execution state export type ExecutionStatus = @@ -138,6 +140,23 @@ interface ReviewCommentsProps { onClear: () => void; } +export interface SessionQueueStateProps { + state: 'queued' | 'pasting' | 'pasted' | 'failed'; + label: string; + failureReason?: string | null; + canSendAgain?: boolean; + onSendAgain?: () => void; + sendAgainLabel?: string; + isSendingAgain?: boolean; +} + +export interface SessionToastProps { + message: string; + tone: ToastTone; + onDismiss: () => void; + dismissLabel: string; +} + export interface SessionChatBoxEditorRenderProps< TExecutor extends string = string, > { @@ -170,6 +189,9 @@ interface SessionChatBoxProps { toolbarActions?: ToolbarActionsProps; modelSelector?: ReactNode; error?: string | null; + queueState?: SessionQueueStateProps | null; + toast?: SessionToastProps | null; + canStop?: boolean; repoIds?: string[]; agent?: TExecutor | null; executor?: ExecutorProps; @@ -235,6 +257,9 @@ export function SessionChatBox({ toolbarActions, modelSelector, error, + queueState, + toast, + canStop = true, repoIds, agent, executor, @@ -528,12 +553,14 @@ export function SessionChatBox({ disabled={!canSend} value={t('conversation.actions.queue')} /> - + {canStop && ( + + )} ); @@ -545,12 +572,14 @@ export function SessionChatBox({ value={t('conversation.actions.cancelQueue')} actionIcon={XIcon} /> - + {canStop && ( + + )} ); @@ -619,17 +648,64 @@ export function SessionChatBox({ ); } - // Queued message banner - if (isQueued) { + // Durable queue / CLI delivery truth state + if (queueState) { + const isFailure = queueState.state === 'failed'; + const QueueIcon = isFailure + ? WarningIcon + : queueState.state === 'pasting' + ? SpinnerIcon + : ClockIcon; banners.push(
- - - {t('followUp.queuedMessage')} - + + + {queueState.failureReason && ( + + {queueState.failureReason} + + )} + {queueState.canSendAgain && queueState.onSendAgain && ( + + )}
); } @@ -653,93 +729,125 @@ export function SessionChatBox({ }; return ( - - {/* New session mode: agent icon + executor dropdown */} - {isNewSessionMode && executor && ( - <> - {renderAgentIcon?.(agent, 'size-icon-xl')} - - - {t('conversation.executors')} - - {executor.options.map((exec) => ( - executor.onChange(exec)} - > - {formatExecutorLabel(exec)} - - ))} - - - )} - {/* Existing session mode: show in-progress todo when running, otherwise file stats */} - {!isNewSessionMode && ( - <> - {isRunning && inProgressTodo ? ( - - - {inProgressTodo.content} - - ) : ( - <> - {stats?.hasConflicts && ( - - )} - {onOpenWorkspace ? ( - - ) : onViewCode ? ( - + {toast && ( + + )} + + {/* New session mode: agent icon + executor dropdown */} + {isNewSessionMode && executor && ( + <> + {renderAgentIcon?.(agent, 'size-icon-xl')} + + + {t('conversation.executors')} + + {executor.options.map((exec) => ( + executor.onChange(exec)} > - + {formatExecutorLabel(exec)} + + ))} + + + )} + {/* Existing session mode: show in-progress todo when running, otherwise file stats */} + {!isNewSessionMode && ( + <> + {isRunning && inProgressTodo ? ( + + + {inProgressTodo.content} + + ) : ( + <> + {stats?.hasConflicts && ( + + )} + {onOpenWorkspace ? ( + + ) : onViewCode ? ( + + + + {t('diff.filesChanged', { count: filesChanged })} + + {(linesAdded !== undefined || + linesRemoved !== undefined) && ( + + {linesAdded !== undefined && ( + + +{linesAdded} + + )} + {linesRemoved !== undefined && ( + + -{linesRemoved} + + )} + + )} + + + ) : ( + {t('diff.filesChanged', { count: filesChanged })} @@ -759,166 +867,149 @@ export function SessionChatBox({ )} - - ) : ( - - - {t('diff.filesChanged', { count: filesChanged })} - - {(linesAdded !== undefined || - linesRemoved !== undefined) && ( - - {linesAdded !== undefined && ( - +{linesAdded} - )} - {linesRemoved !== undefined && ( - -{linesRemoved} - )} - - )} - - )} - - )} - - )} - - } - headerRight={ - <> - {/* Turn navigation + Agent icon for existing session mode */} - {!isNewSessionMode && ( - <> - {onScrollToPreviousMessage && ( - {})} - getActiveTurnPatchKey={getActiveTurnPatchKey} - > - - - )} - {renderAgentIcon?.(agent, 'size-icon-xl')} - - )} - {/* Todo progress popup - always rendered, disabled when no todos */} - - {supportsContextUsage && ( - - )} - - {/* New Session option */} - onNewSession?.()} - > - {t('conversation.sessions.newSession')} - - {sessions.length > 0 && } - {sessions.length > 0 ? ( + + )} + + )} + + } + headerRight={ + <> + {/* Turn navigation + Agent icon for existing session mode */} + {!isNewSessionMode && ( <> - - {t('conversation.sessions.label')} - - {sessions.map((s, index) => ( - onSelectSession(s.id)} + {onScrollToPreviousMessage && ( + {})} + getActiveTurnPatchKey={getActiveTurnPatchKey} > - - {renderAgentIcon?.( - s.executor ?? null, - 'size-icon shrink-0' + - {s.name - ? s.name - : index === 0 - ? t('conversation.sessions.latest') - : formatSessionDate(s.created_at)} - - - - ))} + onClick={onScrollToPreviousMessage} + /> + + )} + {renderAgentIcon?.(agent, 'size-icon-xl')} - ) : ( - - {t('conversation.sessions.noPreviousSessions')} - )} - {onRenameSession && selectedSessionId && !isNewSessionMode && ( - <> - - - onRenameSession( - selectedSessionId, - selectedSessionObj?.name ?? '' - ) - } - > - {t('conversation.sessions.rename')} - - + {/* Todo progress popup - always rendered, disabled when no todos */} + + {supportsContextUsage && ( + )} - - - } - footerLeft={ - <> - - - {onPrCommentClick && ( + + {/* New Session option */} + onNewSession?.()} + > + {t('conversation.sessions.newSession')} + + {sessions.length > 0 && } + {sessions.length > 0 ? ( + <> + + {t('conversation.sessions.label')} + + {sessions.map((s, index) => ( + onSelectSession(s.id)} + > + + {renderAgentIcon?.( + s.executor ?? null, + 'size-icon shrink-0' + )} + + {s.name + ? s.name + : index === 0 + ? t('conversation.sessions.latest') + : formatSessionDate(s.created_at)} + + + + ))} + + ) : ( + + {t('conversation.sessions.noPreviousSessions')} + + )} + {onRenameSession && selectedSessionId && !isNewSessionMode && ( + <> + + + onRenameSession( + selectedSessionId, + selectedSessionObj?.name ?? '' + ) + } + > + {t('conversation.sessions.rename')} + + + )} + + + } + footerLeft={ + <> - )} - {toolbarActions?.items.map((item) => ( - - ))} - - } - footerRight={renderActionButtons()} - /> + {onPrCommentClick && ( + + )} + {toolbarActions?.items.map((item) => ( + + ))} + + } + footerRight={renderActionButtons()} + /> + ); } diff --git a/packages/ui/src/components/Toast.tsx b/packages/ui/src/components/Toast.tsx new file mode 100644 index 00000000000..174c5775525 --- /dev/null +++ b/packages/ui/src/components/Toast.tsx @@ -0,0 +1,71 @@ +import { + CheckCircleIcon, + InfoIcon, + WarningCircleIcon, + XCircleIcon, + XIcon, +} from '@phosphor-icons/react'; + +import { cn } from '../lib/cn'; + +export type ToastTone = 'info' | 'success' | 'warning' | 'error'; + +export interface ToastProps { + message: string; + tone?: ToastTone; + onDismiss?: () => void; + dismissLabel?: string; + className?: string; +} + +const toneStyles: Record = { + info: 'text-normal', + success: 'text-success', + warning: 'text-warning', + error: 'text-error', +}; + +const toneIcons = { + info: InfoIcon, + success: CheckCircleIcon, + warning: WarningCircleIcon, + error: XCircleIcon, +} satisfies Record; + +/** Controlled, reusable status toast for local app surfaces. */ +export function Toast({ + message, + tone = 'info', + onDismiss, + dismissLabel = 'Dismiss notification', + className, +}: ToastProps) { + const StatusIcon = toneIcons[tone]; + const isError = tone === 'error'; + + return ( +
+
+ ); +} diff --git a/packages/web-core/src/features/workspace-chat/model/collaborationUiState.test.ts b/packages/web-core/src/features/workspace-chat/model/collaborationUiState.test.ts new file mode 100644 index 00000000000..3a9ae1053a8 --- /dev/null +++ b/packages/web-core/src/features/workspace-chat/model/collaborationUiState.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import type { + DispatchOutcome, + ExecutionProcess, + ExecutorConfig, + QueuedMessage, + QueueStatus, +} from 'shared/types'; + +import { + deriveQueueChipState, + mapDispatchOutcomeToUiState, +} from './collaborationUiState'; + +const executorConfig = { executor: 'CLAUDE_CODE' } as ExecutorConfig; + +function queuedMessage(overrides: Partial = {}): QueuedMessage { + return { + id: 'queue-id', + session_id: 'session-id', + data: { + message: 'Keep the exact queued text', + executor_config: executorConfig, + }, + source: 'ui', + state: 'queued', + failure_reason: null, + claude_session_id: null, + pasted_at: null, + acked_at: null, + queued_at: '2026-07-21T10:00:00.000Z', + updated_at: '2026-07-21T10:00:00.000Z', + ...overrides, + }; +} + +function queueStatus( + status: 'queued' | 'pasting' | 'pasted', + message = queuedMessage({ state: status }) +): QueueStatus { + return { status, message } as QueueStatus; +} + +describe('mapDispatchOutcomeToUiState', () => { + it('maps a started outcome to normal composer cleanup without a notice', () => { + const outcome: DispatchOutcome = { + outcome: 'started', + execution_process: {} as ExecutionProcess, + }; + + expect(mapDispatchOutcomeToUiState(outcome)).toEqual({ + kind: 'started', + notice: 'none', + queueStatus: { status: 'empty' }, + clearComposer: true, + confirmReplacement: false, + }); + }); + + it('maps every non-started outcome to visible queue state or replacement confirmation', () => { + const queued = queueStatus('queued'); + const pasted = queueStatus('pasted'); + + expect( + mapDispatchOutcomeToUiState({ outcome: 'queued', status: queued }) + ).toMatchObject({ + kind: 'queued', + notice: 'held', + queueStatus: queued, + clearComposer: true, + confirmReplacement: false, + }); + expect( + mapDispatchOutcomeToUiState({ + outcome: 'routed_to_cli', + delivery: pasted, + }) + ).toMatchObject({ + kind: 'routed-to-cli', + notice: 'routed-to-cli', + queueStatus: pasted, + clearComposer: true, + confirmReplacement: false, + }); + expect( + mapDispatchOutcomeToUiState({ outcome: 'conflict', status: queued }) + ).toMatchObject({ + kind: 'conflict', + notice: 'none', + queueStatus: queued, + clearComposer: false, + confirmReplacement: true, + }); + }); + + it('marks a queued delivery failure for an explicit notice', () => { + const status = queueStatus( + 'queued', + queuedMessage({ failure_reason: 'CLI paste failed; queued for retry' }) + ); + + expect( + mapDispatchOutcomeToUiState({ outcome: 'queued', status }).notice + ).toBe('delivery-failed'); + }); + + it('distinguishes the started, queued, and routed outcomes used by fork recovery', () => { + const started: DispatchOutcome = { + outcome: 'started', + execution_process: {} as ExecutionProcess, + }; + const queued = queueStatus('queued'); + const pasted = queueStatus('pasted'); + + expect(mapDispatchOutcomeToUiState(started).kind).toBe('started'); + expect( + mapDispatchOutcomeToUiState({ outcome: 'queued', status: queued }).kind + ).toBe('queued'); + expect( + mapDispatchOutcomeToUiState({ + outcome: 'routed_to_cli', + delivery: pasted, + }).kind + ).toBe('routed-to-cli'); + }); +}); + +describe('deriveQueueChipState', () => { + it('derives queued, pasting, and pasted persistent chip states', () => { + expect(deriveQueueChipState(queueStatus('queued'))?.kind).toBe('queued'); + expect(deriveQueueChipState(queueStatus('pasting'))?.kind).toBe('pasting'); + expect(deriveQueueChipState(queueStatus('pasted'))?.kind).toBe('pasted'); + expect(deriveQueueChipState({ status: 'empty' })).toBeNull(); + }); + + it('turns failure_reason into a retryable notice with Send again eligibility', () => { + const message = queuedMessage({ + source: 'recovery', + failure_reason: 'executor start failed; queued for retry', + }); + + expect(deriveQueueChipState(queueStatus('queued', message))).toEqual({ + kind: 'failed', + message, + failureReason: 'executor start failed; queued for retry', + canSendAgain: true, + }); + }); + + it('does not offer Send again while a failed delivery is already in flight', () => { + const chip = deriveQueueChipState( + queueStatus( + 'pasting', + queuedMessage({ + state: 'pasting', + failure_reason: 'Previous delivery failed', + }) + ) + ); + + expect(chip?.kind).toBe('failed'); + expect(chip?.canSendAgain).toBe(false); + }); +}); diff --git a/packages/web-core/src/features/workspace-chat/model/collaborationUiState.ts b/packages/web-core/src/features/workspace-chat/model/collaborationUiState.ts new file mode 100644 index 00000000000..a341dc7c496 --- /dev/null +++ b/packages/web-core/src/features/workspace-chat/model/collaborationUiState.ts @@ -0,0 +1,106 @@ +import type { DispatchOutcome, QueuedMessage, QueueStatus } from 'shared/types'; + +export type DispatchUiKind = + | 'started' + | 'queued' + | 'routed-to-cli' + | 'conflict'; + +export type DispatchNoticeKind = + | 'none' + | 'held' + | 'delivery-failed' + | 'routed-to-cli'; + +export interface DispatchUiState { + kind: DispatchUiKind; + notice: DispatchNoticeKind; + queueStatus: QueueStatus; + clearComposer: boolean; + confirmReplacement: boolean; +} + +export type QueueChipKind = 'queued' | 'pasting' | 'pasted' | 'failed'; + +export interface QueueChipState { + kind: QueueChipKind; + message: QueuedMessage; + failureReason: string | null; + canSendAgain: boolean; +} + +export function getActiveQueuedMessage( + status: QueueStatus +): QueuedMessage | null { + return status.status === 'empty' ? null : status.message; +} + +/** + * Convert the backend's dispatch decision into explicit composer behavior. + * Keeping this pure makes it impossible for a new outcome to be silently + * treated as a successful executor start. + */ +export function mapDispatchOutcomeToUiState( + outcome: DispatchOutcome +): DispatchUiState { + switch (outcome.outcome) { + case 'started': + return { + kind: 'started', + notice: 'none', + queueStatus: { status: 'empty' }, + clearComposer: true, + confirmReplacement: false, + }; + case 'queued': { + const message = getActiveQueuedMessage(outcome.status); + return { + kind: 'queued', + notice: message?.failure_reason ? 'delivery-failed' : 'held', + queueStatus: outcome.status, + clearComposer: true, + confirmReplacement: false, + }; + } + case 'routed_to_cli': + return { + kind: 'routed-to-cli', + notice: 'routed-to-cli', + queueStatus: outcome.delivery, + clearComposer: true, + confirmReplacement: false, + }; + case 'conflict': + return { + kind: 'conflict', + notice: 'none', + queueStatus: outcome.status, + clearComposer: false, + confirmReplacement: true, + }; + } +} + +/** Derive the persistent composer chip from the durable queue slot. */ +export function deriveQueueChipState( + status: QueueStatus +): QueueChipState | null { + if (status.status === 'empty') return null; + + const failureReason = status.message.failure_reason; + if (failureReason) { + return { + kind: 'failed', + message: status.message, + failureReason, + canSendAgain: status.status === 'queued', + }; + } + + return { + kind: status.status, + message: status.message, + failureReason: null, + canSendAgain: false, + }; +} diff --git a/packages/web-core/src/features/workspace-chat/model/hooks/useCreateSession.ts b/packages/web-core/src/features/workspace-chat/model/hooks/useCreateSession.ts index f97a4a23d63..737afaf96df 100644 --- a/packages/web-core/src/features/workspace-chat/model/hooks/useCreateSession.ts +++ b/packages/web-core/src/features/workspace-chat/model/hooks/useCreateSession.ts @@ -5,6 +5,7 @@ import { workspaceSessionKeys } from '@/shared/hooks/workspaceSessionKeys'; import type { Session, CreateFollowUpAttempt, + DispatchOutcome, ExecutorConfig, } from 'shared/types'; @@ -14,6 +15,11 @@ interface CreateSessionParams { executorConfig: ExecutorConfig; } +interface CreatedSessionDispatch { + session: Session; + outcome: DispatchOutcome; +} + /** * Hook for creating a new session and sending the first message. * Uses TanStack Query mutation for proper cache management. @@ -27,7 +33,7 @@ export function useCreateSession() { workspaceId, prompt, executorConfig, - }: CreateSessionParams): Promise => { + }: CreateSessionParams): Promise => { const session = await sessionsApi.create({ workspace_id: workspaceId, }); @@ -38,12 +44,13 @@ export function useCreateSession() { retry_process_id: null, force_when_dirty: null, perform_git_reset: null, + replace: false, }; - await sessionsApi.followUp(session.id, body); + const outcome = await sessionsApi.followUp(session.id, body); - return session; + return { session, outcome }; }, - onSuccess: (session) => { + onSuccess: ({ session }) => { // Invalidate session queries to refresh the list queryClient.invalidateQueries({ queryKey: workspaceSessionKeys.byWorkspace( diff --git a/packages/web-core/src/features/workspace-chat/model/hooks/useMessageEditRetry.ts b/packages/web-core/src/features/workspace-chat/model/hooks/useMessageEditRetry.ts index 663d773d5f6..83f40cd1c88 100644 --- a/packages/web-core/src/features/workspace-chat/model/hooks/useMessageEditRetry.ts +++ b/packages/web-core/src/features/workspace-chat/model/hooks/useMessageEditRetry.ts @@ -60,6 +60,7 @@ export function useMessageEditRetry( retry_process_id: executionProcessId, force_when_dirty: modalResult.forceWhenDirty ?? false, perform_git_reset: modalResult.performGitReset ?? true, + replace: false, }); }, onSuccess: () => { diff --git a/packages/web-core/src/features/workspace-chat/model/hooks/useSessionQueueInteraction.ts b/packages/web-core/src/features/workspace-chat/model/hooks/useSessionQueueInteraction.ts index c5ca063627b..7ccd3adda8f 100644 --- a/packages/web-core/src/features/workspace-chat/model/hooks/useSessionQueueInteraction.ts +++ b/packages/web-core/src/features/workspace-chat/model/hooks/useSessionQueueInteraction.ts @@ -1,97 +1,155 @@ import { useCallback } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { queueApi } from '@/shared/lib/api'; -import type { ExecutorConfig, QueueStatus } from 'shared/types'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ApiError, queueApi } from '@/shared/lib/api'; +import type { ExecutorConfig, QueuedMessage, QueueStatus } from 'shared/types'; interface UseSessionQueueInteractionOptions { /** Session ID for queue operations */ sessionId: string | undefined; } +export type QueueMessageResult = + | { outcome: 'stored'; status: QueueStatus } + | { outcome: 'conflict'; status: QueueStatus }; + interface UseSessionQueueInteractionResult { - /** Whether a message is currently queued */ + /** The complete durable queue state, including CLI delivery phases. */ + queueStatus: QueueStatus; + /** Whether the active slot can still be edited or cancelled. */ isQueued: boolean; - /** The queued message content, if any */ + /** The active queue record in any delivery phase. */ + activeMessage: QueuedMessage | null; + /** The editable queued message content, if any. */ queuedMessage: string | null; - /** The executor config from the queued message, if any */ + /** The executor config from the editable queued message, if any. */ queuedConfig: ExecutorConfig | null; - /** Whether a queue operation is in progress */ + /** Whether a queue operation is in progress. */ isQueueLoading: boolean; - /** Queue a message for later execution */ + /** Queue a message for later execution. */ queueMessage: ( message: string, - executorConfig: ExecutorConfig - ) => Promise; - /** Cancel the queued message */ + executorConfig: ExecutorConfig, + replace?: boolean + ) => Promise; + /** Re-enqueue the current failed slot with explicit replacement. */ + retryQueuedMessage: () => Promise; + /** Cancel the queued message. */ cancelQueue: () => Promise; - /** Refresh queue status from server */ + /** Update cached queue truth immediately from a dispatch response. */ + setQueueStatus: (status: QueueStatus) => void; + /** Refresh queue status from server. */ refreshQueueStatus: () => Promise; } const QUEUE_STATUS_KEY = 'queue-status'; +export const sessionQueueKeys = { + status: (sessionId: string | undefined) => + [QUEUE_STATUS_KEY, sessionId] as const, +}; + +function queueResultFromError(error: unknown): QueueMessageResult | null { + if (!(error instanceof ApiError)) return null; + const queueError = error as ApiError; + if (queueError.statusCode !== 409 || !queueError.error_data) return null; + return { outcome: 'conflict', status: queueError.error_data }; +} + /** - * Hook to manage queue interaction for session messages. - * Uses TanStack Query for caching and mutation handling. + * Manage the session's durable single-slot queue. Active deliveries poll once + * per second so pasted -> imported/empty transitions clear the composer truth + * state promptly; empty sessions poll more lightly to notice another surface. */ export function useSessionQueueInteraction({ sessionId, }: UseSessionQueueInteractionOptions): UseSessionQueueInteractionResult { const queryClient = useQueryClient(); + const queryKey = sessionQueueKeys.status(sessionId); - // Query for queue status const { data: queueStatus = { status: 'empty' as const }, refetch } = useQuery({ - queryKey: [QUEUE_STATUS_KEY, sessionId], + queryKey, queryFn: () => queueApi.getStatus(sessionId!), - enabled: !!sessionId, + enabled: Boolean(sessionId), + refetchInterval: (query) => { + const status = query.state.data; + return status && status.status !== 'empty' ? 1_000 : 5_000; + }, }); - const isQueued = queueStatus.status === 'queued'; - const queuedMessageData = isQueued - ? (queueStatus as Extract).message - : null; - const queuedMessage = queuedMessageData?.data.message ?? null; - const queuedConfig: ExecutorConfig | null = - queuedMessageData?.data.executor_config ?? null; + const activeMessage = + queueStatus.status === 'empty' ? null : queueStatus.message; + const editableMessage = + queueStatus.status === 'queued' ? queueStatus.message : null; + const isQueued = editableMessage !== null; + const queuedMessage = editableMessage?.data.message ?? null; + const queuedConfig = editableMessage?.data.executor_config ?? null; + + const setQueueStatus = useCallback( + (status: QueueStatus) => { + queryClient.setQueryData(queryKey, status); + }, + [queryClient, queryKey] + ); - // Mutation for queueing a message const queueMutation = useMutation({ - mutationFn: ({ + mutationFn: async ({ message, executorConfig, + replace, }: { message: string; executorConfig: ExecutorConfig; - }) => - queueApi.queue(sessionId!, { - message, - executor_config: executorConfig, - }), - onSuccess: (status) => { - queryClient.setQueryData([QUEUE_STATUS_KEY, sessionId], status); + replace: boolean; + }): Promise => { + try { + const status = await queueApi.queue(sessionId!, { + message, + executor_config: executorConfig, + replace, + }); + return { outcome: 'stored', status }; + } catch (error) { + const conflict = queueResultFromError(error); + if (conflict) return conflict; + throw error; + } + }, + onSuccess: (result) => { + setQueueStatus(result.status); }, }); - // Mutation for cancelling the queue const cancelMutation = useMutation({ mutationFn: () => queueApi.cancel(sessionId!), - onSuccess: (status) => { - queryClient.setQueryData([QUEUE_STATUS_KEY, sessionId], status); - }, + onSuccess: setQueueStatus, }); const queueMessage = useCallback( - async (message: string, executorConfig: ExecutorConfig) => { - if (!sessionId) return; - await queueMutation.mutateAsync({ + async ( + message: string, + executorConfig: ExecutorConfig, + replace = false + ) => { + if (!sessionId) return null; + return queueMutation.mutateAsync({ message, executorConfig, + replace, }); }, [sessionId, queueMutation] ); + const retryQueuedMessage = useCallback(async () => { + if (!editableMessage) return null; + return queueMutation.mutateAsync({ + message: editableMessage.data.message, + executorConfig: editableMessage.data.executor_config, + replace: true, + }); + }, [editableMessage, queueMutation]); + const cancelQueue = useCallback(async () => { if (!sessionId) return; await cancelMutation.mutateAsync(); @@ -103,12 +161,16 @@ export function useSessionQueueInteraction({ }, [sessionId, refetch]); return { + queueStatus, isQueued, + activeMessage, queuedMessage, queuedConfig, isQueueLoading: queueMutation.isPending || cancelMutation.isPending, queueMessage, + retryQueuedMessage, cancelQueue, + setQueueStatus, refreshQueueStatus, }; } diff --git a/packages/web-core/src/features/workspace-chat/model/hooks/useSessionSend.ts b/packages/web-core/src/features/workspace-chat/model/hooks/useSessionSend.ts index 2e829efb416..33cf819f075 100644 --- a/packages/web-core/src/features/workspace-chat/model/hooks/useSessionSend.ts +++ b/packages/web-core/src/features/workspace-chat/model/hooks/useSessionSend.ts @@ -1,5 +1,5 @@ import { useCallback, useState } from 'react'; -import type { ExecutorConfig } from 'shared/types'; +import type { DispatchOutcome, ExecutorConfig } from 'shared/types'; import { sessionsApi } from '@/shared/lib/api'; import { useCreateSession } from './useCreateSession'; @@ -17,8 +17,12 @@ interface UseSessionSendOptions { } interface UseSessionSendResult { - /** Send a message. Returns true on success, false on failure. */ - send: (message: string) => Promise; + /** Send a message. Returns the accepted dispatch decision or null on failure. */ + send: ( + message: string, + replace?: boolean, + targetSessionId?: string + ) => Promise; /** Whether a send operation is in progress */ isSending: boolean; /** Error message if send failed */ @@ -27,13 +31,19 @@ interface UseSessionSendResult { clearError: () => void; } +export type SessionSendResult = { + kind: 'dispatch'; + sessionId: string; + outcome: DispatchOutcome; +} | null; + /** * Hook for sending messages in SessionChatBoxContainer. * Handles both new session creation and existing session follow-up. * * Unlike useFollowUpSend, this hook: * - Takes message/variant as parameters to send() (not captured in closure) - * - Returns boolean for success/failure (caller handles cleanup) + * - Returns the backend dispatch outcome (caller handles cleanup and notices) * - Has no prompt composition (no conflict/review/clicked markdown) */ export function useSessionSend({ @@ -49,54 +59,64 @@ export function useSessionSend({ const [error, setError] = useState(null); const send = useCallback( - async (message: string): Promise => { + async ( + message: string, + replace = false, + targetSessionId?: string + ): Promise => { const trimmed = message.trim(); - if (!trimmed) return false; + if (!trimmed) return null; if (!executorConfig) { setError('No executor selected'); - return false; + return null; } setError(null); - if (isNewSessionMode) { + if (isNewSessionMode && !targetSessionId) { // New session flow if (!workspaceId) { setError('No workspace selected'); - return false; + return null; } try { - const session = await createSession({ + const { session, outcome } = await createSession({ workspaceId, prompt: trimmed, executorConfig, }); onSelectSession?.(session.id); - return true; + return { kind: 'dispatch', sessionId: session.id, outcome }; } catch (e: unknown) { const err = e as { message?: string }; setError( `Failed to create session: ${err.message ?? 'Unknown error'}` ); - return false; + return null; } } else { // Existing session flow - if (!sessionId) return false; + const dispatchSessionId = targetSessionId ?? sessionId; + if (!dispatchSessionId) return null; setIsSendingFollowUp(true); try { - await sessionsApi.followUp(sessionId, { + const outcome = await sessionsApi.followUp(dispatchSessionId, { prompt: trimmed, executor_config: executorConfig, retry_process_id: null, force_when_dirty: null, perform_git_reset: null, + replace, }); - return true; + return { + kind: 'dispatch', + sessionId: dispatchSessionId, + outcome, + }; } catch (e: unknown) { const err = e as { message?: string }; setError(`Failed to send: ${err.message ?? 'Unknown error'}`); - return false; + return null; } finally { setIsSendingFollowUp(false); } diff --git a/packages/web-core/src/features/workspace-chat/ui/SessionChatBoxContainer.tsx b/packages/web-core/src/features/workspace-chat/ui/SessionChatBoxContainer.tsx index ff8be7305d2..e2d7c350ce0 100644 --- a/packages/web-core/src/features/workspace-chat/ui/SessionChatBoxContainer.tsx +++ b/packages/web-core/src/features/workspace-chat/ui/SessionChatBoxContainer.tsx @@ -1,12 +1,14 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useDropzone } from 'react-dropzone'; +import { useTranslation } from 'react-i18next'; import { type AskUserQuestionItem, BaseAgentCapability, type Session, type BaseCodingAgent, ExecutionProcessStatus, + type QueueStatus, } from 'shared/types'; import { AgentIcon } from '@/shared/components/AgentIcon'; import { useHostId } from '@/shared/providers/HostIdProvider'; @@ -25,7 +27,10 @@ import { useTodos } from '../model/hooks/useTodos'; import { getLatestConfigFromProcesses } from '@/shared/lib/executor'; import { useExecutorConfig } from '@/shared/hooks/useExecutorConfig'; import { useSessionMessageEditor } from '../model/hooks/useSessionMessageEditor'; -import { useSessionQueueInteraction } from '../model/hooks/useSessionQueueInteraction'; +import { + sessionQueueKeys, + useSessionQueueInteraction, +} from '../model/hooks/useSessionQueueInteraction'; import { useSessionSend } from '../model/hooks/useSessionSend'; import { useSessionAttachments } from '../model/hooks/useSessionAttachments'; import { useMessageEditRetry } from '../model/hooks/useMessageEditRetry'; @@ -65,6 +70,14 @@ import { useAppNavigation } from '@/shared/hooks/useAppNavigation'; import { sessionsApi } from '@/shared/lib/api'; import { RenameSessionDialog } from '@vibe/ui/components/RenameSessionDialog'; import type { TurnNavigationItem } from '@vibe/ui/components/TurnNavigationPopup'; +import { ConfirmDialog } from '@vibe/ui/components/ConfirmDialog'; +import { + deriveQueueChipState, + getActiveQueuedMessage, + mapDispatchOutcomeToUiState, + type DispatchNoticeKind, +} from '../model/collaborationUiState'; +import { useTransientToast } from '@/shared/hooks/useTransientToast'; /** Compute execution status from boolean flags */ function computeExecutionStatus(params: { @@ -141,6 +154,8 @@ type SessionChatBoxContainerProps = | PlaceholderProps; export function SessionChatBoxContainer(props: SessionChatBoxContainerProps) { + const { t } = useTranslation('tasks'); + const { toast, showToast, dismissToast } = useTransientToast(); const { mode, sessions, @@ -475,12 +490,15 @@ export function SessionChatBoxContainer(props: SessionChatBoxContainerProps) { // Queue interaction const { + queueStatus, isQueued, queuedMessage, queuedConfig, isQueueLoading, queueMessage, + retryQueuedMessage, cancelQueue, + setQueueStatus, refreshQueueStatus, } = useSessionQueueInteraction({ sessionId }); @@ -498,6 +516,65 @@ export function SessionChatBoxContainer(props: SessionChatBoxContainerProps) { executorConfig, }); + const showDispatchNotice = useCallback( + (notice: DispatchNoticeKind) => { + switch (notice) { + case 'held': + showToast(t('conversation.queue.heldToast'), 'warning'); + break; + case 'delivery-failed': + showToast(t('conversation.queue.deliveryFailedToast'), 'warning'); + break; + case 'routed-to-cli': + showToast(t('conversation.queue.routedToast'), 'info'); + break; + case 'none': + break; + } + }, + [showToast, t] + ); + + const confirmQueueReplacement = useCallback( + async (status: QueueStatus): Promise => { + const current = getActiveQueuedMessage(status); + if (!current) { + showToast(t('conversation.queue.replaceChangedToast'), 'warning'); + return false; + } + + const result = await ConfirmDialog.show({ + title: t('conversation.queue.replaceTitle'), + message: + current.source === 'recovery' + ? t('conversation.queue.replaceRecoveryMessage', { + message: current.data.message, + }) + : t('conversation.queue.replaceUiMessage', { + message: current.data.message, + }), + confirmText: t('conversation.queue.replaceConfirm'), + cancelText: t('conversation.actions.cancel'), + variant: 'destructive', + }); + + return result === 'confirmed'; + }, + [showToast, t] + ); + + const showReplacementConflict = useCallback( + (status: QueueStatus) => { + showToast( + status.status === 'pasting' || status.status === 'pasted' + ? t('conversation.queue.replaceInProgressToast') + : t('conversation.queue.replaceChangedToast'), + 'warning' + ); + }, + [showToast, t] + ); + const handleSend = useCallback(async () => { const { prompt, isSlashCommand } = buildAgentPrompt(localMessage, [ reviewMarkdown, @@ -505,26 +582,61 @@ export function SessionChatBoxContainer(props: SessionChatBoxContainerProps) { onScrollToBottom('auto'); - const success = await send(prompt); - if (success) { - cancelDebouncedSave(); - setLocalMessage(''); - clearUploadedAttachments(); - if (isNewSessionMode) await clearDraft(); - if (!isSlashCommand) { - reviewContext?.clearComments(); + let dispatchSessionId: string | undefined; + const attemptDispatch = async (replace: boolean): Promise => { + const result = await send(prompt, replace, dispatchSessionId); + if (!result) return false; + dispatchSessionId = result.sessionId; + + const uiState = mapDispatchOutcomeToUiState(result.outcome); + if (result.sessionId === sessionId) { + setQueueStatus(uiState.queueStatus); + } else { + queryClient.setQueryData( + sessionQueueKeys.status(result.sessionId), + uiState.queueStatus + ); + } + + if (uiState.confirmReplacement) { + if (replace) { + showReplacementConflict(uiState.queueStatus); + return false; + } + const confirmed = await confirmQueueReplacement(uiState.queueStatus); + return confirmed ? attemptDispatch(true) : false; } + + showDispatchNotice(uiState.notice); + return uiState.clearComposer; + }; + + const accepted = await attemptDispatch(false); + if (!accepted) return; + + cancelDebouncedSave(); + setLocalMessage(''); + clearUploadedAttachments(); + if (isNewSessionMode) await clearDraft(); + if (!isSlashCommand) { + reviewContext?.clearComments(); + } + requestAnimationFrame(() => { requestAnimationFrame(() => { - requestAnimationFrame(() => { - onScrollToBottom('auto'); - }); + onScrollToBottom('auto'); }); - } + }); }, [ onScrollToBottom, send, localMessage, reviewMarkdown, + sessionId, + queryClient, + setQueueStatus, + showReplacementConflict, + confirmQueueReplacement, + showDispatchNotice, cancelDebouncedSave, setLocalMessage, clearUploadedAttachments, @@ -560,26 +672,65 @@ export function SessionChatBoxContainer(props: SessionChatBoxContainerProps) { const { prompt } = buildAgentPrompt(localMessage, [reviewMarkdown]); - cancelDebouncedSave(); - await saveToScratch(localMessage, executorConfig); - await queueMessage(prompt, executorConfig); + const attemptQueue = async (replace: boolean): Promise => { + const result = await queueMessage(prompt, executorConfig, replace); + if (!result) return false; + if (result.outcome === 'stored') return true; + + if (replace) { + showReplacementConflict(result.status); + return false; + } + const confirmed = await confirmQueueReplacement(result.status); + return confirmed ? attemptQueue(true) : false; + }; + + try { + cancelDebouncedSave(); + await saveToScratch(localMessage, executorConfig); + const accepted = await attemptQueue(false); + if (!accepted) return; + } catch { + showToast(t('conversation.queue.queueErrorToast'), 'error'); + return; + } // Clear local state after queueing (same as handleSend) setLocalMessage(''); clearUploadedAttachments(); reviewContext?.clearComments(); + showDispatchNotice('held'); }, [ localMessage, reviewMarkdown, executorConfig, queueMessage, + showReplacementConflict, + confirmQueueReplacement, cancelDebouncedSave, saveToScratch, setLocalMessage, clearUploadedAttachments, reviewContext, + showToast, + showDispatchNotice, + t, ]); + const handleRetryQueuedMessage = useCallback(async () => { + try { + const result = await retryQueuedMessage(); + if (!result) return; + if (result.outcome === 'conflict') { + showReplacementConflict(result.status); + return; + } + showToast(t('conversation.queue.retryingToast'), 'info'); + } catch { + showToast(t('conversation.queue.retryErrorToast'), 'error'); + } + }, [retryQueuedMessage, showReplacementConflict, showToast, t]); + // Editor change handler const handleEditorChange = useCallback( (value: string) => { @@ -887,6 +1038,39 @@ export function SessionChatBoxContainer(props: SessionChatBoxContainerProps) { isAttemptRunning, }); + const queueChip = useMemo( + () => deriveQueueChipState(queueStatus), + [queueStatus] + ); + const queueState = useMemo(() => { + if (!queueChip) return null; + + const label = (() => { + switch (queueChip.kind) { + case 'queued': + return t('conversation.queue.queued'); + case 'pasting': + return t('conversation.queue.pasting'); + case 'pasted': + return t('conversation.queue.pasted'); + case 'failed': + return t('conversation.queue.failed'); + } + })(); + + return { + state: queueChip.kind, + label, + failureReason: queueChip.failureReason, + canSendAgain: queueChip.canSendAgain, + onSendAgain: handleRetryQueuedMessage, + sendAgainLabel: isQueueLoading + ? t('conversation.queue.sendingAgain') + : t('conversation.queue.sendAgain'), + isSendingAgain: isQueueLoading, + }; + }, [queueChip, handleRetryQueuedMessage, isQueueLoading, t]); + // During loading, render with empty editor to preserve container UI // In approval mode, don't show queued message - it's for follow-up, not approval response const editorValue = useMemo(() => { @@ -1059,6 +1243,18 @@ export function SessionChatBoxContainer(props: SessionChatBoxContainerProps) { onResolveConflicts: handleResolveConflicts, }} error={sendError} + queueState={queueState} + toast={ + toast + ? { + message: toast.message, + tone: toast.tone, + onDismiss: dismissToast, + dismissLabel: t('conversation.queue.dismissToast'), + } + : null + } + canStop={isAttemptRunning} agent={effectiveExecutor} todos={todos} inProgressTodo={inProgressTodo} diff --git a/packages/web-core/src/i18n/locales/en/tasks.json b/packages/web-core/src/i18n/locales/en/tasks.json index 0440dec9272..3a57f6cf28b 100644 --- a/packages/web-core/src/i18n/locales/en/tasks.json +++ b/packages/web-core/src/i18n/locales/en/tasks.json @@ -105,9 +105,6 @@ "copyLogs": "Copy logs", "logsCopied": "Copied!" }, - "followUp": { - "queuedMessage": "Message queued - will execute when current run finishes" - }, "todoPopup": { "title": "Tasks", "progress": "{{completed}}/{{total}} completed", @@ -129,6 +126,27 @@ "retry": "Retry", "showMore": "Show more", "showLess": "Show less", + "queue": { + "queued": "Queued — waiting for the agent", + "pasting": "Sending to CLI…", + "pasted": "Queued in CLI — not yet submitted", + "failed": "Delivery paused", + "sendAgain": "Send again", + "sendingAgain": "Sending…", + "heldToast": "Held — the agent is busy; your message is queued and will go next", + "deliveryFailedToast": "Held — delivery failed; your message is queued. Use Send again to retry now.", + "routedToast": "Sent to CLI — waiting for it to appear in the conversation", + "retryingToast": "Message queued again — it will retry next", + "replaceTitle": "Replace queued message?", + "replaceUiMessage": "Replace your queued message: “{{message}}”?", + "replaceRecoveryMessage": "Replace the recovered CLI message: “{{message}}”?", + "replaceConfirm": "Replace and send", + "replaceInProgressToast": "That message is already being delivered. Wait for it to finish before sending another.", + "replaceChangedToast": "The queued message changed before it could be replaced. Try again.", + "queueErrorToast": "Your message could not be queued. Try again.", + "retryErrorToast": "The queued message could not be sent again. Try again.", + "dismissToast": "Dismiss notification" + }, "actions": { "cancel": "Cancel", "submitFeedback": "Submit Feedback", diff --git a/packages/web-core/src/shared/dialogs/tasks/ResolveConflictsDialog.tsx b/packages/web-core/src/shared/dialogs/tasks/ResolveConflictsDialog.tsx index eb631734182..daf131a9821 100644 --- a/packages/web-core/src/shared/dialogs/tasks/ResolveConflictsDialog.tsx +++ b/packages/web-core/src/shared/dialogs/tasks/ResolveConflictsDialog.tsx @@ -174,6 +174,7 @@ const ResolveConflictsDialogImpl = create( retry_process_id: null, force_when_dirty: null, perform_git_reset: null, + replace: false, }); // Invalidate queries and wait for them to complete diff --git a/packages/web-core/src/shared/hooks/useRetryProcess.ts b/packages/web-core/src/shared/hooks/useRetryProcess.ts index c18fba6b7c9..f08c58a8c68 100644 --- a/packages/web-core/src/shared/hooks/useRetryProcess.ts +++ b/packages/web-core/src/shared/hooks/useRetryProcess.ts @@ -62,6 +62,7 @@ export function useRetryProcess( retry_process_id: executionProcessId, force_when_dirty: modalResult.forceWhenDirty ?? false, perform_git_reset: modalResult.performGitReset ?? true, + replace: false, }); }, onSuccess: () => { diff --git a/packages/web-core/src/shared/hooks/useTransientToast.ts b/packages/web-core/src/shared/hooks/useTransientToast.ts new file mode 100644 index 00000000000..1f0eb732da4 --- /dev/null +++ b/packages/web-core/src/shared/hooks/useTransientToast.ts @@ -0,0 +1,34 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export type TransientToastTone = 'info' | 'success' | 'warning' | 'error'; + +export interface TransientToastState { + id: number; + message: string; + tone: TransientToastTone; +} + +export function useTransientToast(durationMs = 5_000) { + const sequenceRef = useRef(0); + const [toast, setToast] = useState(null); + + useEffect(() => { + if (!toast) return; + const timeout = window.setTimeout(() => { + setToast((current) => (current?.id === toast.id ? null : current)); + }, durationMs); + return () => window.clearTimeout(timeout); + }, [durationMs, toast]); + + const showToast = useCallback( + (message: string, tone: TransientToastTone = 'info') => { + sequenceRef.current += 1; + setToast({ id: sequenceRef.current, message, tone }); + }, + [] + ); + + const dismissToast = useCallback(() => setToast(null), []); + + return { toast, showToast, dismissToast }; +} diff --git a/packages/web-core/src/shared/lib/api.ts b/packages/web-core/src/shared/lib/api.ts index 05f381a4312..765dd711ea9 100644 --- a/packages/web-core/src/shared/lib/api.ts +++ b/packages/web-core/src/shared/lib/api.ts @@ -39,7 +39,6 @@ import { CliAgentAvailability, BaseCodingAgent, ExecutorConfig, - DraftFollowUpData, AgentPresetOptionsQuery, RunAgentSetupRequest, RunAgentSetupResponse, @@ -68,7 +67,10 @@ import { PushError, TokenResponse, CurrentUserResponse, + DispatchOutcome, + ForkRecoveryRequest, QueueStatus, + QueueMessageRequest, PrCommentsResponse, MergeWorkspaceRequest, PushWorkspaceRequest, @@ -252,12 +254,16 @@ export const handleApiResponse = async ( ): Promise => { if (!response.ok) { let errorMessage = `Request failed with status ${response.status}`; + let structuredError: E | undefined; try { - const errorData = await response.json(); - if (errorData.message) { - errorMessage = errorData.message; + const errorBody = (await response.json()) as Partial< + ApiResponse + >; + if (errorBody.message) { + errorMessage = errorBody.message; } + structuredError = errorBody.error_data ?? undefined; } catch { // Fallback to status text if JSON parsing fails errorMessage = response.statusText || errorMessage; @@ -270,7 +276,12 @@ export const handleApiResponse = async ( endpoint: response.url, timestamp: new Date().toISOString(), }); - throw new ApiError(errorMessage, response.status, response); + throw new ApiError( + errorMessage, + response.status, + response, + structuredError + ); } if (response.status === 204) { @@ -316,6 +327,24 @@ export const handleApiResponse = async ( return result.data as T; }; +async function handleDispatchResponse( + response: Response +): Promise { + try { + return await handleApiResponse(response); + } catch (error) { + const dispatchError = error as ApiError; + if ( + error instanceof ApiError && + dispatchError.statusCode === 409 && + dispatchError.error_data?.outcome === 'conflict' + ) { + return dispatchError.error_data; + } + throw error; + } +} + // Sessions API export const sessionsApi = { getByWorkspace: async (workspaceId: string): Promise => { @@ -345,12 +374,26 @@ export const sessionsApi = { followUp: async ( sessionId: string, data: CreateFollowUpAttempt - ): Promise => { + ): Promise => { const response = await makeRequest(`/api/sessions/${sessionId}/follow-up`, { method: 'POST', body: JSON.stringify(data), }); - return handleApiResponse(response); + return handleDispatchResponse(response); + }, + + forkRecovery: async ( + sessionId: string, + data: ForkRecoveryRequest + ): Promise => { + const response = await makeRequest( + `/api/sessions/${sessionId}/fork-recovery`, + { + method: 'POST', + body: JSON.stringify(data), + } + ); + return handleDispatchResponse(response); }, startReview: async ( @@ -1668,13 +1711,13 @@ export const queueApi = { */ queue: async ( sessionId: string, - data: DraftFollowUpData + data: QueueMessageRequest ): Promise => { const response = await makeRequest(`/api/sessions/${sessionId}/queue`, { method: 'POST', body: JSON.stringify(data), }); - return handleApiResponse(response); + return handleApiResponse(response); }, /** From 4f2a48115a456f0fca5646a8385cc53e184d2ca7 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 18:35:13 +0000 Subject: [PATCH 15/36] feat(web): track foreign conversation writers --- .../model/foreignWriterBanner.test.ts | 28 +++++++++++++++++++ .../model/foreignWriterBanner.ts | 22 +++++++++++++++ .../model/hooks/useConversationHistory.ts | 9 +++++- .../model/hooks/useSessionNativeFeed.ts | 1 + 4 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 packages/web-core/src/features/workspace-chat/model/foreignWriterBanner.test.ts create mode 100644 packages/web-core/src/features/workspace-chat/model/foreignWriterBanner.ts diff --git a/packages/web-core/src/features/workspace-chat/model/foreignWriterBanner.test.ts b/packages/web-core/src/features/workspace-chat/model/foreignWriterBanner.test.ts new file mode 100644 index 00000000000..cbe5e390aa8 --- /dev/null +++ b/packages/web-core/src/features/workspace-chat/model/foreignWriterBanner.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldShowForeignWriterBanner } from './foreignWriterBanner'; + +describe('shouldShowForeignWriterBanner', () => { + const firstSeenAt = '2026-07-21T10:00:00.000Z'; + + it('shows a newly detected foreign writer', () => { + expect(shouldShowForeignWriterBanner(firstSeenAt, null)).toBe(true); + }); + + it('keeps the dismissed timestamp hidden', () => { + expect(shouldShowForeignWriterBanner(firstSeenAt, firstSeenAt)).toBe(false); + }); + + it('re-raises the banner only for a newer foreign_writer_seen_at', () => { + expect( + shouldShowForeignWriterBanner('2026-07-21T10:01:00.000Z', firstSeenAt) + ).toBe(true); + expect( + shouldShowForeignWriterBanner('2026-07-21T09:59:00.000Z', firstSeenAt) + ).toBe(false); + }); + + it('stays hidden when detection has not reported a writer', () => { + expect(shouldShowForeignWriterBanner(null, firstSeenAt)).toBe(false); + }); +}); diff --git a/packages/web-core/src/features/workspace-chat/model/foreignWriterBanner.ts b/packages/web-core/src/features/workspace-chat/model/foreignWriterBanner.ts new file mode 100644 index 00000000000..f8be53ac420 --- /dev/null +++ b/packages/web-core/src/features/workspace-chat/model/foreignWriterBanner.ts @@ -0,0 +1,22 @@ +/** + * A dismissal suppresses only the writer event the user saw. A later native + * feed timestamp must raise the banner again. + */ +export function shouldShowForeignWriterBanner( + foreignWriterSeenAt: string | null, + dismissedAt: string | null +): boolean { + if (!foreignWriterSeenAt) return false; + if (!dismissedAt) return true; + if (foreignWriterSeenAt === dismissedAt) return false; + + const seenTime = Date.parse(foreignWriterSeenAt); + const dismissedTime = Date.parse(dismissedAt); + if (Number.isFinite(seenTime) && Number.isFinite(dismissedTime)) { + return seenTime > dismissedTime; + } + + // Backend timestamps are ISO-8601. Keep deterministic ordering if a future + // transport supplies a value Date.parse does not recognise. + return foreignWriterSeenAt > dismissedAt; +} diff --git a/packages/web-core/src/features/workspace-chat/model/hooks/useConversationHistory.ts b/packages/web-core/src/features/workspace-chat/model/hooks/useConversationHistory.ts index af52c4a233c..60e8e580bc2 100644 --- a/packages/web-core/src/features/workspace-chat/model/hooks/useConversationHistory.ts +++ b/packages/web-core/src/features/workspace-chat/model/hooks/useConversationHistory.ts @@ -22,6 +22,8 @@ export interface UseConversationHistoryResult { isFirstTurn: boolean; /** Whether background batches are still loading older history entries */ isLoadingHistory: boolean; + /** Latest native turn attributed to a writer outside app-managed surfaces. */ + foreignWriterSeenAt: string | null; } import { MIN_INITIAL_ENTRIES, @@ -576,5 +578,10 @@ export const useConversationHistory = ({ } }, [scopeKey, idListKey, executionProcessesRaw]); - return { isFirstTurn, isLoadingHistory: isLoadingHistoryState }; + return { + isFirstTurn, + isLoadingHistory: isLoadingHistoryState, + foreignWriterSeenAt: + nativeFeed.snapshot?.health.foreign_writer_seen_at ?? null, + }; }; diff --git a/packages/web-core/src/features/workspace-chat/model/hooks/useSessionNativeFeed.ts b/packages/web-core/src/features/workspace-chat/model/hooks/useSessionNativeFeed.ts index 937f1d3ca78..135f921fa6a 100644 --- a/packages/web-core/src/features/workspace-chat/model/hooks/useSessionNativeFeed.ts +++ b/packages/web-core/src/features/workspace-chat/model/hooks/useSessionNativeFeed.ts @@ -29,6 +29,7 @@ function createEmptyNativeFeedSnapshot(): NativeFeedSnapshot { rescans: 0n, quarantined_files: 0n, watch_degraded: false, + foreign_writer_seen_at: null, files: [], }, }; From d743ba390965422dd4e6ec17819dd5b9b7c10718 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 18:35:30 +0000 Subject: [PATCH 16/36] feat(web): add fork recovery and writer alerts --- .../ui/src/components/ChatForkBranches.tsx | 32 ++- .../model/deriveConversationTimeline.ts | 1 + .../model/deriveNativeForkGroups.test.ts | 3 + .../model/deriveNativeForkGroups.ts | 5 + .../ui/ConversationListContainer.tsx | 225 ++++++++++++++++-- .../web-core/src/i18n/locales/en/common.json | 14 +- .../hooks/useConversationHistory/types.ts | 1 + 7 files changed, 253 insertions(+), 28 deletions(-) diff --git a/packages/ui/src/components/ChatForkBranches.tsx b/packages/ui/src/components/ChatForkBranches.tsx index c8fe89c6634..eecec209a92 100644 --- a/packages/ui/src/components/ChatForkBranches.tsx +++ b/packages/ui/src/components/ChatForkBranches.tsx @@ -1,19 +1,27 @@ import { useState, type ReactNode, type SyntheticEvent } from 'react'; -import { GitBranchIcon } from '@phosphor-icons/react'; +import { + ArrowCounterClockwiseIcon, + GitBranchIcon, +} from '@phosphor-icons/react'; import { Badge } from './Badge'; +import { PrimaryButton } from './PrimaryButton'; export interface ChatForkBranch { id: string; label: string; isDefault: boolean; content: ReactNode; + onBringBack?: () => void; + isBringingBack?: boolean; } interface ChatForkBranchesProps { explanation: string; resumeHint: string; emptyBranchLabel: string; + bringBackLabel: string; + bringingBackLabel: string; branches: ChatForkBranch[]; } @@ -21,10 +29,14 @@ function ForkBranch({ branch, resumeHint, emptyBranchLabel, + bringBackLabel, + bringingBackLabel, }: { branch: ChatForkBranch; resumeHint: string; emptyBranchLabel: string; + bringBackLabel: string; + bringingBackLabel: string; }) { const [open, setOpen] = useState(branch.isDefault); const handleToggle = (event: SyntheticEvent) => { @@ -52,6 +64,20 @@ function ForkBranch({ {emptyBranchLabel}

)} + {!branch.isDefault && branch.onBringBack && ( +
+ +
+ )} ); @@ -61,6 +87,8 @@ export function ChatForkBranches({ explanation, resumeHint, emptyBranchLabel, + bringBackLabel, + bringingBackLabel, branches, }: ChatForkBranchesProps) { return ( @@ -76,6 +104,8 @@ export function ChatForkBranches({ branch={branch} resumeHint={resumeHint} emptyBranchLabel={emptyBranchLabel} + bringBackLabel={bringBackLabel} + bringingBackLabel={bringingBackLabel} /> ))} diff --git a/packages/web-core/src/features/workspace-chat/model/deriveConversationTimeline.ts b/packages/web-core/src/features/workspace-chat/model/deriveConversationTimeline.ts index 7ae6b5af483..7218921afe9 100644 --- a/packages/web-core/src/features/workspace-chat/model/deriveConversationTimeline.ts +++ b/packages/web-core/src/features/workspace-chat/model/deriveConversationTimeline.ts @@ -73,6 +73,7 @@ function deriveForkAwareDisplayEntries( item.section, item.section.branches.map((branch) => ({ isDefault: branch.isDefault, + branchLeafUuid: branch.branchLeafUuid, entries: aggregateRenderableEntries( branch.entries.map((entry) => ({ ...entry, diff --git a/packages/web-core/src/features/workspace-chat/model/deriveNativeForkGroups.test.ts b/packages/web-core/src/features/workspace-chat/model/deriveNativeForkGroups.test.ts index 12ee6dd262d..819e9ac11c5 100644 --- a/packages/web-core/src/features/workspace-chat/model/deriveNativeForkGroups.test.ts +++ b/packages/web-core/src/features/workspace-chat/model/deriveNativeForkGroups.test.ts @@ -128,15 +128,18 @@ describe('deriveNativeForkEntrySections', () => { expect( section.branches.map((branch) => ({ isDefault: branch.isDefault, + branchLeafUuid: branch.branchLeafUuid, entries: branch.entries.map((entry) => entry.patchKey), })) ).toEqual([ { isDefault: false, + branchLeafUuid: 'branch-1-root', entries: ['native:branch-1-root'], }, { isDefault: true, + branchLeafUuid: 'branch-2-root', entries: ['native:branch-2-root'], }, ]); diff --git a/packages/web-core/src/features/workspace-chat/model/deriveNativeForkGroups.ts b/packages/web-core/src/features/workspace-chat/model/deriveNativeForkGroups.ts index 2d5bc8137f0..d598153e122 100644 --- a/packages/web-core/src/features/workspace-chat/model/deriveNativeForkGroups.ts +++ b/packages/web-core/src/features/workspace-chat/model/deriveNativeForkGroups.ts @@ -8,6 +8,7 @@ import type { export interface NativeForkEntryBranch { isDefault: boolean; + branchLeafUuid: string; entries: PatchTypeWithKey[]; entryIndices: number[]; } @@ -98,6 +99,10 @@ export function deriveNativeForkEntrySections( return { isDefault: feedFork.fork.default_branch === branchIndex, + branchLeafUuid: + branch.leaf_uuids[branch.leaf_uuids.length - 1] ?? + branch.node_uuids[branch.node_uuids.length - 1] ?? + branch.root_uuid, entries: branchEntries, entryIndices, }; diff --git a/packages/web-core/src/features/workspace-chat/ui/ConversationListContainer.tsx b/packages/web-core/src/features/workspace-chat/ui/ConversationListContainer.tsx index 1c02a46fa19..42ad6d54a12 100644 --- a/packages/web-core/src/features/workspace-chat/ui/ConversationListContainer.tsx +++ b/packages/web-core/src/features/workspace-chat/ui/ConversationListContainer.tsx @@ -8,10 +8,13 @@ import { useState, type MouseEvent, } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { CircleIcon, SpinnerIcon, TerminalWindowIcon, + WarningIcon, + XIcon, } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; @@ -56,6 +59,12 @@ import { ScriptFixerDialog } from '@/shared/dialogs/scripts/ScriptFixerDialog'; import { ChatForkBranches } from '@vibe/ui/components/ChatForkBranches'; import { Badge } from '@vibe/ui/components/Badge'; import { UnassignedCliSessions } from './UnassignedCliSessions'; +import { shouldShowForeignWriterBanner } from '../model/foreignWriterBanner'; +import { sessionsApi } from '@/shared/lib/api'; +import { mapDispatchOutcomeToUiState } from '../model/collaborationUiState'; +import { sessionQueueKeys } from '../model/hooks/useSessionQueueInteraction'; +import { useTransientToast } from '@/shared/hooks/useTransientToast'; +import { Toast } from '@vibe/ui/components/Toast'; interface ConversationListProps { attempt: WorkspaceWithSession; @@ -108,7 +117,9 @@ function renderRowContent( entry: DisplayEntry, attempt: WorkspaceWithSession, resetAction: UseResetProcessResult, - repos: RepoWithTargetBranch[] + repos: RepoWithTargetBranch[], + onRecoverFork: (forkParentUuid: string, branchLeafUuid: string) => void, + recoveringForkKey: string | null ): React.ReactNode { if (isNativeForkDisplayGroup(entry)) { return ( @@ -117,6 +128,8 @@ function renderRowContent( attempt={attempt} resetAction={resetAction} repos={repos} + onRecoverFork={onRecoverFork} + recoveringForkKey={recoveringForkKey} /> ); } @@ -204,11 +217,15 @@ function NativeForkGroupEntry({ attempt, resetAction, repos, + onRecoverFork, + recoveringForkKey, }: { group: NativeForkDisplayGroup; attempt: WorkspaceWithSession; resetAction: UseResetProcessResult; repos: RepoWithTargetBranch[]; + onRecoverFork: (forkParentUuid: string, branchLeafUuid: string) => void; + recoveringForkKey: string | null; }) { const { t } = useTranslation('common'); @@ -218,18 +235,33 @@ function NativeForkGroupEntry({ explanation={t('conversation.fork.explanation')} resumeHint={t('conversation.fork.resumeHint')} emptyBranchLabel={t('conversation.fork.emptyBranch')} + bringBackLabel={t('conversation.fork.bringBack')} + bringingBackLabel={t('conversation.fork.bringingBack')} branches={group.branches.map((branch, index) => ({ id: `${group.patchKey}:${index}:${branch.isDefault ? 'default' : 'alternate'}`, label: t('conversation.fork.branchLabel', { index: index + 1, }), isDefault: branch.isDefault, + onBringBack: branch.isDefault + ? undefined + : () => onRecoverFork(group.forkParentUuid, branch.branchLeafUuid), + isBringingBack: + recoveringForkKey === + `${group.forkParentUuid}:${branch.branchLeafUuid}`, content: branch.entries.length > 0 ? ( <> {branch.entries.map((entry) => (
- {renderRowContent(entry, attempt, resetAction, repos)} + {renderRowContent( + entry, + attempt, + resetAction, + repos, + onRecoverFork, + recoveringForkKey + )}
))} @@ -255,6 +287,12 @@ export const ConversationList = forwardRef< ref ) { const { t } = useTranslation('common'); + const queryClient = useQueryClient(); + const { + toast: recoveryToast, + showToast: showRecoveryToast, + dismissToast: dismissRecoveryToast, + } = useTransientToast(); const repos = reposProp; const unassignedCliSessions = useUnassignedCliSessions( cliAvailable ? attempt.id : undefined, @@ -277,6 +315,13 @@ export const ConversationList = forwardRef< const [hasSetupScriptRun, setHasSetupScriptRun] = useState(false); const [hasCleanupScriptRun, setHasCleanupScriptRun] = useState(false); const [hasRunningProcess, setHasRunningProcess] = useState(false); + const [dismissedForeignWriter, setDismissedForeignWriter] = useState<{ + scopeKey: string; + seenAt: string; + } | null>(null); + const [recoveringForkKey, setRecoveringForkKey] = useState( + null + ); const lastSettledTailStartIndexRef = useRef(null); const { setEntries, reset } = useEntriesActions(); const setTokenUsageInfo = useSetTokenUsageInfo(); @@ -501,11 +546,82 @@ export const ConversationList = forwardRef< } }; - const { isFirstTurn, isLoadingHistory } = useConversationHistory({ - attempt, - onTimelineUpdated, - scopeKey: conversationScopeKey, - }); + const { isFirstTurn, isLoadingHistory, foreignWriterSeenAt } = + useConversationHistory({ + attempt, + onTimelineUpdated, + scopeKey: conversationScopeKey, + }); + const dismissedForeignWriterAt = + dismissedForeignWriter?.scopeKey === conversationScopeKey + ? dismissedForeignWriter.seenAt + : null; + const showForeignWriterBanner = shouldShowForeignWriterBanner( + foreignWriterSeenAt, + dismissedForeignWriterAt + ); + const dismissForeignWriterBanner = useCallback(() => { + if (foreignWriterSeenAt) { + setDismissedForeignWriter({ + scopeKey: conversationScopeKey, + seenAt: foreignWriterSeenAt, + }); + } + }, [conversationScopeKey, foreignWriterSeenAt]); + const handleRecoverFork = useCallback( + async (forkParentUuid: string, branchLeafUuid: string) => { + const sessionId = attempt.session?.id; + if (!sessionId) return; + + const recoveryKey = `${forkParentUuid}:${branchLeafUuid}`; + setRecoveringForkKey(recoveryKey); + try { + const outcome = await sessionsApi.forkRecovery(sessionId, { + fork_parent_uuid: forkParentUuid, + branch_leaf_uuid: branchLeafUuid, + }); + const uiState = mapDispatchOutcomeToUiState(outcome); + queryClient.setQueryData( + sessionQueueKeys.status(sessionId), + uiState.queueStatus + ); + + switch (uiState.kind) { + case 'started': + showRecoveryToast( + t('conversation.fork.recoveryStartedToast'), + 'success' + ); + break; + case 'queued': + showRecoveryToast( + t('conversation.fork.recoveryQueuedToast'), + 'warning' + ); + break; + case 'routed-to-cli': + showRecoveryToast( + t('conversation.fork.recoveryRoutedToast'), + 'info' + ); + break; + case 'conflict': + showRecoveryToast( + t('conversation.fork.recoveryConflictToast'), + 'warning' + ); + break; + } + } catch { + showRecoveryToast(t('conversation.fork.recoveryErrorToast'), 'error'); + } finally { + setRecoveringForkKey((current) => + current === recoveryKey ? null : current + ); + } + }, + [attempt.session?.id, queryClient, showRecoveryToast, t] + ); const prevEntriesRef = useRef([]); const prevRowsRef = useRef([]); @@ -874,30 +990,73 @@ export const ConversationList = forwardRef< return (
- {(cliSessionActive || unassignedCliSessions.sessions.length > 0) && ( -
- - {cliSessionActive && ( - 0) && ( +
+ {showForeignWriterBanner && ( +
-
+ )} + {(cliSessionActive || + unassignedCliSessions.sessions.length > 0) && ( +
+ + {cliSessionActive && ( + + + )} +
)}
)} + {recoveryToast && ( + + )} {showLoader && (
@@ -1008,7 +1167,14 @@ export const ConversationList = forwardRef< transform: `translateY(${virtualItem.start}px)`, }} > - {renderRowContent(row.entry, attempt, resetAction, repos)} + {renderRowContent( + row.entry, + attempt, + resetAction, + repos, + handleRecoverFork, + recoveringForkKey + )}
); })} @@ -1023,7 +1189,14 @@ export const ConversationList = forwardRef< data-row-index={rowIndex} data-semantic-key={row.semanticKey} > - {renderRowContent(row.entry, attempt, resetAction, repos)} + {renderRowContent( + row.entry, + attempt, + resetAction, + repos, + handleRecoverFork, + recoveringForkKey + )}
); })} diff --git a/packages/web-core/src/i18n/locales/en/common.json b/packages/web-core/src/i18n/locales/en/common.json index 1597f1a4364..d0ee2753ec0 100644 --- a/packages/web-core/src/i18n/locales/en/common.json +++ b/packages/web-core/src/i18n/locales/en/common.json @@ -75,6 +75,10 @@ "assistant": "Claude", "viaCli": "via CLI", "cliSessionActive": "CLI session active", + "foreignWriter": { + "message": "This conversation is being edited outside the app.", + "dismiss": "Dismiss outside-editor notice" + }, "quarantine": { "affordance": "Unassigned CLI", "openLabel": "Open {{count}} unassigned CLI conversations", @@ -100,7 +104,15 @@ "explanation": "Some messages went down a path the agent is no longer following.", "branchLabel": "Branch {{index}}", "resumeHint": "Claude resumes here", - "emptyBranch": "No visible messages in this branch" + "emptyBranch": "No visible messages in this branch", + "bringBack": "Bring these back", + "bringingBack": "Bringing these back…", + "recoveryStartedToast": "Recovered messages sent to the agent", + "recoveryQueuedToast": "Recovery held — the agent is busy; these messages will go next", + "recoveryRoutedToast": "Recovery sent to CLI — waiting for it to appear in the conversation", + "recoveryConflictToast": "Recovery not sent — another message is already queued", + "recoveryErrorToast": "These messages could not be brought back. Try again.", + "dismissToast": "Dismiss recovery notification" }, "loadingEarlierMessages": "Loading earlier messages", "cliEmptyTitle": "This workspace usually runs in the terminal", diff --git a/packages/web-core/src/shared/hooks/useConversationHistory/types.ts b/packages/web-core/src/shared/hooks/useConversationHistory/types.ts index 3ca1893660e..de10378211b 100644 --- a/packages/web-core/src/shared/hooks/useConversationHistory/types.ts +++ b/packages/web-core/src/shared/hooks/useConversationHistory/types.ts @@ -81,6 +81,7 @@ export type BaseDisplayEntry = export interface NativeForkDisplayBranch { isDefault: boolean; + branchLeafUuid: string; entries: BaseDisplayEntry[]; } From 67c89d5a16aadcaf8691c9f3015860dec5f00710 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 21:48:56 +0000 Subject: [PATCH 17/36] fix(i18n): add missing Phase 2 collaboration translations --- .../web-core/src/i18n/locales/es/common.json | 14 ++++++++++- .../web-core/src/i18n/locales/es/tasks.json | 24 ++++++++++++++++--- .../web-core/src/i18n/locales/fr/common.json | 14 ++++++++++- .../web-core/src/i18n/locales/fr/tasks.json | 24 ++++++++++++++++--- .../web-core/src/i18n/locales/ja/common.json | 14 ++++++++++- .../web-core/src/i18n/locales/ja/tasks.json | 24 ++++++++++++++++--- .../web-core/src/i18n/locales/ko/common.json | 14 ++++++++++- .../web-core/src/i18n/locales/ko/tasks.json | 24 ++++++++++++++++--- .../src/i18n/locales/zh-Hans/common.json | 14 ++++++++++- .../src/i18n/locales/zh-Hans/tasks.json | 24 ++++++++++++++++--- .../src/i18n/locales/zh-Hant/common.json | 14 ++++++++++- .../src/i18n/locales/zh-Hant/tasks.json | 24 ++++++++++++++++--- 12 files changed, 204 insertions(+), 24 deletions(-) diff --git a/packages/web-core/src/i18n/locales/es/common.json b/packages/web-core/src/i18n/locales/es/common.json index 03618107e4e..d6389435396 100644 --- a/packages/web-core/src/i18n/locales/es/common.json +++ b/packages/web-core/src/i18n/locales/es/common.json @@ -83,6 +83,10 @@ "assistant": "Claude", "viaCli": "vía CLI", "cliSessionActive": "Sesión CLI activa", + "foreignWriter": { + "message": "Esta conversación se está editando fuera de la aplicación.", + "dismiss": "Cerrar aviso de edición externa" + }, "quarantine": { "affordance": "CLI sin asignar", "openLabel": "Abrir {{count}} conversaciones CLI sin asignar", @@ -108,7 +112,15 @@ "explanation": "Algunos mensajes siguieron un camino que el agente ya no está siguiendo.", "branchLabel": "Rama {{index}}", "resumeHint": "Claude continúa aquí", - "emptyBranch": "No hay mensajes visibles en esta rama" + "emptyBranch": "No hay mensajes visibles en esta rama", + "bringBack": "Recuperar estos mensajes", + "bringingBack": "Recuperando estos mensajes…", + "recoveryStartedToast": "Los mensajes recuperados se enviaron al agente", + "recoveryQueuedToast": "Recuperación en espera — el agente está ocupado; estos mensajes se enviarán a continuación", + "recoveryRoutedToast": "Recuperación enviada a la CLI — esperando a que aparezca en la conversación", + "recoveryConflictToast": "No se envió la recuperación — ya hay otro mensaje en cola", + "recoveryErrorToast": "No se pudieron recuperar estos mensajes. Inténtalo de nuevo.", + "dismissToast": "Cerrar notificación de recuperación" }, "loadingEarlierMessages": "Cargando mensajes anteriores", "cliEmptyTitle": "Este workspace normalmente se ejecuta en la terminal", diff --git a/packages/web-core/src/i18n/locales/es/tasks.json b/packages/web-core/src/i18n/locales/es/tasks.json index 89628741636..6af7e41ca9f 100644 --- a/packages/web-core/src/i18n/locales/es/tasks.json +++ b/packages/web-core/src/i18n/locales/es/tasks.json @@ -111,9 +111,6 @@ "filesChanged_one": "{{count}} file changed", "filesChanged_other": "{{count}} files changed" }, - "followUp": { - "queuedMessage": "Mensaje en cola - se ejecutará cuando finalice la ejecución actual" - }, "git": { "branch": { "current": "actual" @@ -386,6 +383,27 @@ "retry": "Reintentar", "showMore": "Mostrar más", "showLess": "Mostrar menos", + "queue": { + "queued": "En cola — esperando al agente", + "pasting": "Enviando a la CLI…", + "pasted": "En cola en la CLI — aún no se ha enviado", + "failed": "Envío en pausa", + "sendAgain": "Enviar de nuevo", + "sendingAgain": "Enviando…", + "heldToast": "En espera — el agente está ocupado; tu mensaje está en cola y se enviará a continuación", + "deliveryFailedToast": "En espera — falló el envío; tu mensaje está en cola. Usa «Enviar de nuevo» para reintentarlo ahora.", + "routedToast": "Enviado a la CLI — esperando a que aparezca en la conversación", + "retryingToast": "Mensaje puesto de nuevo en cola — se reintentará a continuación", + "replaceTitle": "¿Reemplazar el mensaje en cola?", + "replaceUiMessage": "¿Reemplazar tu mensaje en cola: «{{message}}»?", + "replaceRecoveryMessage": "¿Reemplazar el mensaje recuperado de la CLI: «{{message}}»?", + "replaceConfirm": "Reemplazar y enviar", + "replaceInProgressToast": "Ese mensaje ya se está enviando. Espera a que termine antes de enviar otro.", + "replaceChangedToast": "El mensaje en cola cambió antes de que se pudiera reemplazar. Inténtalo de nuevo.", + "queueErrorToast": "No se pudo poner tu mensaje en cola. Inténtalo de nuevo.", + "retryErrorToast": "No se pudo volver a enviar el mensaje en cola. Inténtalo de nuevo.", + "dismissToast": "Cerrar notificación" + }, "actions": { "cancel": "Cancelar", "submitFeedback": "Enviar comentarios", diff --git a/packages/web-core/src/i18n/locales/fr/common.json b/packages/web-core/src/i18n/locales/fr/common.json index 553c316e0bd..befab22a1f9 100644 --- a/packages/web-core/src/i18n/locales/fr/common.json +++ b/packages/web-core/src/i18n/locales/fr/common.json @@ -70,6 +70,10 @@ "assistant": "Claude", "viaCli": "via la CLI", "cliSessionActive": "Session CLI active", + "foreignWriter": { + "message": "Cette conversation est en cours de modification en dehors de l'application.", + "dismiss": "Fermer l'avis de modification externe" + }, "quarantine": { "affordance": "CLI non attribuées", "openLabel": "Ouvrir {{count}} conversations CLI non attribuées", @@ -95,7 +99,15 @@ "explanation": "Certains messages ont suivi un chemin que l'agent ne suit plus.", "branchLabel": "Branche {{index}}", "resumeHint": "Claude reprend ici", - "emptyBranch": "Aucun message visible dans cette branche" + "emptyBranch": "Aucun message visible dans cette branche", + "bringBack": "Récupérer ces messages", + "bringingBack": "Récupération des messages…", + "recoveryStartedToast": "Messages récupérés envoyés à l'agent", + "recoveryQueuedToast": "Récupération en attente — l'agent est occupé ; ces messages seront envoyés ensuite", + "recoveryRoutedToast": "Récupération envoyée à la CLI — en attente de son affichage dans la conversation", + "recoveryConflictToast": "Récupération non envoyée — un autre message est déjà en file d'attente", + "recoveryErrorToast": "Ces messages n'ont pas pu être récupérés. Réessayez.", + "dismissToast": "Fermer la notification de récupération" }, "loadingEarlierMessages": "Chargement des messages précédents", "cliEmptyTitle": "Cet espace de travail s'exécute généralement dans le terminal", diff --git a/packages/web-core/src/i18n/locales/fr/tasks.json b/packages/web-core/src/i18n/locales/fr/tasks.json index e1f61dcccf0..13cec7dbc9f 100644 --- a/packages/web-core/src/i18n/locales/fr/tasks.json +++ b/packages/web-core/src/i18n/locales/fr/tasks.json @@ -105,9 +105,6 @@ "copyLogs": "Copier les logs", "logsCopied": "Copié !" }, - "followUp": { - "queuedMessage": "Message en file d'attente - s'exécutera lorsque l'exécution actuelle sera terminée" - }, "todoPopup": { "title": "Tâches", "progress": "{{completed}}/{{total}} terminées", @@ -129,6 +126,27 @@ "retry": "Réessayer", "showMore": "Afficher plus", "showLess": "Afficher moins", + "queue": { + "queued": "En file d'attente — en attente de l'agent", + "pasting": "Envoi à la CLI…", + "pasted": "En file d'attente dans la CLI — pas encore soumis", + "failed": "Envoi suspendu", + "sendAgain": "Renvoyer", + "sendingAgain": "Nouvel envoi…", + "heldToast": "En attente — l'agent est occupé ; votre message est dans la file et sera envoyé ensuite", + "deliveryFailedToast": "En attente — échec de l'envoi ; votre message est dans la file d'attente. Utilisez « Renvoyer » pour réessayer maintenant.", + "routedToast": "Envoyé à la CLI — en attente de son affichage dans la conversation", + "retryingToast": "Message remis en file d'attente — une nouvelle tentative aura lieu ensuite", + "replaceTitle": "Remplacer le message en file d'attente ?", + "replaceUiMessage": "Remplacer votre message en file d'attente : « {{message}} » ?", + "replaceRecoveryMessage": "Remplacer le message CLI récupéré : « {{message}} » ?", + "replaceConfirm": "Remplacer et envoyer", + "replaceInProgressToast": "Ce message est déjà en cours d'envoi. Attendez la fin de l'envoi avant d'en envoyer un autre.", + "replaceChangedToast": "Le message en file d'attente a changé avant de pouvoir être remplacé. Réessayez.", + "queueErrorToast": "Votre message n'a pas pu être mis en file d'attente. Réessayez.", + "retryErrorToast": "Le message en file d'attente n'a pas pu être renvoyé. Réessayez.", + "dismissToast": "Fermer la notification" + }, "actions": { "cancel": "Annuler", "submitFeedback": "Soumettre le feedback", diff --git a/packages/web-core/src/i18n/locales/ja/common.json b/packages/web-core/src/i18n/locales/ja/common.json index 23fc14b8e45..06f59b3ea90 100644 --- a/packages/web-core/src/i18n/locales/ja/common.json +++ b/packages/web-core/src/i18n/locales/ja/common.json @@ -83,6 +83,10 @@ "assistant": "Claude", "viaCli": "CLI 経由", "cliSessionActive": "CLI セッションがアクティブ", + "foreignWriter": { + "message": "この会話はアプリの外部で編集されています。", + "dismiss": "外部編集の通知を閉じる" + }, "quarantine": { "affordance": "未割り当ての CLI", "openLabel": "未割り当ての CLI 会話 {{count}} 件を開く", @@ -108,7 +112,15 @@ "explanation": "一部のメッセージは、エージェントが現在たどっていない経路に進みました。", "branchLabel": "ブランチ {{index}}", "resumeHint": "Claude はここから再開", - "emptyBranch": "このブランチに表示できるメッセージはありません" + "emptyBranch": "このブランチに表示できるメッセージはありません", + "bringBack": "これらのメッセージを復元", + "bringingBack": "メッセージを復元中…", + "recoveryStartedToast": "復元したメッセージをエージェントに送信しました", + "recoveryQueuedToast": "復元を保留中 — エージェントが処理中のため、これらのメッセージは次に送信されます", + "recoveryRoutedToast": "復元メッセージを CLI に送信しました — 会話に表示されるまでお待ちください", + "recoveryConflictToast": "復元メッセージは送信されませんでした — 別のメッセージがすでにキューに入っています", + "recoveryErrorToast": "これらのメッセージを復元できませんでした。もう一度お試しください。", + "dismissToast": "復元通知を閉じる" }, "loadingEarlierMessages": "以前のメッセージを読み込み中", "cliEmptyTitle": "このワークスペースは通常ターミナルで実行されます", diff --git a/packages/web-core/src/i18n/locales/ja/tasks.json b/packages/web-core/src/i18n/locales/ja/tasks.json index 1ea5828b15e..9433a8ee3e4 100644 --- a/packages/web-core/src/i18n/locales/ja/tasks.json +++ b/packages/web-core/src/i18n/locales/ja/tasks.json @@ -111,9 +111,6 @@ "filesChanged_one": "{{count}} file changed", "filesChanged_other": "{{count}} files changed" }, - "followUp": { - "queuedMessage": "メッセージがキューに追加されました - 現在の実行が完了すると実行されます" - }, "git": { "branch": { "current": "現在" @@ -386,6 +383,27 @@ "retry": "再試行", "showMore": "もっと見る", "showLess": "少なく表示", + "queue": { + "queued": "キューに追加済み — エージェントを待機中", + "pasting": "CLI に送信中…", + "pasted": "CLI のキューに追加済み — まだ送信されていません", + "failed": "送信を一時停止中", + "sendAgain": "再送信", + "sendingAgain": "送信中…", + "heldToast": "保留中 — エージェントが処理中のため、メッセージはキューに追加され、次に送信されます", + "deliveryFailedToast": "保留中 — 送信に失敗しました。メッセージはキューに残っています。今すぐ再試行するには「再送信」を選択してください。", + "routedToast": "CLI に送信しました — 会話に表示されるまでお待ちください", + "retryingToast": "メッセージを再度キューに追加しました — 次に再試行されます", + "replaceTitle": "キュー内のメッセージを置き換えますか?", + "replaceUiMessage": "キュー内のメッセージ「{{message}}」を置き換えますか?", + "replaceRecoveryMessage": "復元した CLI メッセージ「{{message}}」を置き換えますか?", + "replaceConfirm": "置き換えて送信", + "replaceInProgressToast": "そのメッセージはすでに送信中です。完了するまで待ってから、別のメッセージを送信してください。", + "replaceChangedToast": "置き換える前にキュー内のメッセージが変更されました。もう一度お試しください。", + "queueErrorToast": "メッセージをキューに追加できませんでした。もう一度お試しください。", + "retryErrorToast": "キュー内のメッセージを再送信できませんでした。もう一度お試しください。", + "dismissToast": "通知を閉じる" + }, "actions": { "cancel": "キャンセル", "submitFeedback": "フィードバックを送信", diff --git a/packages/web-core/src/i18n/locales/ko/common.json b/packages/web-core/src/i18n/locales/ko/common.json index f88ff0c2ab0..25e24c79699 100644 --- a/packages/web-core/src/i18n/locales/ko/common.json +++ b/packages/web-core/src/i18n/locales/ko/common.json @@ -83,6 +83,10 @@ "assistant": "Claude", "viaCli": "CLI를 통해", "cliSessionActive": "CLI 세션 활성", + "foreignWriter": { + "message": "이 대화는 앱 외부에서 편집 중입니다.", + "dismiss": "외부 편집 알림 닫기" + }, "quarantine": { "affordance": "할당되지 않은 CLI", "openLabel": "할당되지 않은 CLI 대화 {{count}}개 열기", @@ -108,7 +112,15 @@ "explanation": "일부 메시지는 에이전트가 더 이상 따르지 않는 경로로 진행되었습니다.", "branchLabel": "브랜치 {{index}}", "resumeHint": "Claude가 여기서 재개", - "emptyBranch": "이 브랜치에 표시할 메시지가 없습니다" + "emptyBranch": "이 브랜치에 표시할 메시지가 없습니다", + "bringBack": "이 메시지들을 복구하기", + "bringingBack": "메시지 복구 중…", + "recoveryStartedToast": "복구된 메시지를 에이전트에게 보냈습니다", + "recoveryQueuedToast": "복구 보류 — 에이전트가 작업 중이므로 이 메시지들은 다음에 전송됩니다", + "recoveryRoutedToast": "복구 메시지를 CLI로 보냈습니다 — 대화에 표시되기를 기다리는 중입니다", + "recoveryConflictToast": "복구 메시지가 전송되지 않았습니다 — 다른 메시지가 이미 대기열에 있습니다", + "recoveryErrorToast": "이 메시지들을 복구할 수 없습니다. 다시 시도하세요.", + "dismissToast": "복구 알림 닫기" }, "loadingEarlierMessages": "이전 메시지 불러오는 중", "cliEmptyTitle": "이 워크스페이스는 보통 터미널에서 실행됩니다", diff --git a/packages/web-core/src/i18n/locales/ko/tasks.json b/packages/web-core/src/i18n/locales/ko/tasks.json index a0743db2e14..2a6c42b1fc6 100644 --- a/packages/web-core/src/i18n/locales/ko/tasks.json +++ b/packages/web-core/src/i18n/locales/ko/tasks.json @@ -111,9 +111,6 @@ "filesChanged_one": "{{count}} file changed", "filesChanged_other": "{{count}} files changed" }, - "followUp": { - "queuedMessage": "메시지가 대기열에 추가됨 - 현재 실행이 완료되면 실행됩니다" - }, "git": { "labels": { "taskBranch": "작업 브랜치" @@ -386,6 +383,27 @@ "retry": "재시도", "showMore": "더 보기", "showLess": "간략히", + "queue": { + "queued": "대기열에 추가됨 — 에이전트 대기 중", + "pasting": "CLI로 보내는 중…", + "pasted": "CLI 대기열에 추가됨 — 아직 제출되지 않음", + "failed": "전송 일시 중지됨", + "sendAgain": "다시 보내기", + "sendingAgain": "보내는 중…", + "heldToast": "보류됨 — 에이전트가 작업 중이므로 메시지가 대기열에 추가되었으며 다음에 전송됩니다", + "deliveryFailedToast": "보류됨 — 전송에 실패했습니다. 메시지는 대기열에 있습니다. 지금 재시도하려면 ‘다시 보내기’를 사용하세요.", + "routedToast": "CLI로 보냈습니다 — 대화에 표시되기를 기다리는 중입니다", + "retryingToast": "메시지가 대기열에 다시 추가됨 — 다음에 재시도됩니다", + "replaceTitle": "대기 중인 메시지를 바꾸시겠습니까?", + "replaceUiMessage": "대기 중인 메시지 “{{message}}” 대신 새 메시지를 보내시겠습니까?", + "replaceRecoveryMessage": "복구된 CLI 메시지 “{{message}}” 대신 새 메시지를 보내시겠습니까?", + "replaceConfirm": "바꾸고 보내기", + "replaceInProgressToast": "해당 메시지는 이미 전송 중입니다. 완료될 때까지 기다린 후 다른 메시지를 보내세요.", + "replaceChangedToast": "바꾸기 전에 대기열의 메시지가 변경되었습니다. 다시 시도하세요.", + "queueErrorToast": "메시지를 대기열에 추가할 수 없습니다. 다시 시도하세요.", + "retryErrorToast": "대기열의 메시지를 다시 보낼 수 없습니다. 다시 시도하세요.", + "dismissToast": "알림 닫기" + }, "actions": { "cancel": "취소", "submitFeedback": "피드백 제출", diff --git a/packages/web-core/src/i18n/locales/zh-Hans/common.json b/packages/web-core/src/i18n/locales/zh-Hans/common.json index 027e0f6cf33..8429e4d6857 100644 --- a/packages/web-core/src/i18n/locales/zh-Hans/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hans/common.json @@ -70,6 +70,10 @@ "assistant": "Claude", "viaCli": "通过 CLI", "cliSessionActive": "CLI 会话活跃", + "foreignWriter": { + "message": "此对话正在应用外进行编辑。", + "dismiss": "关闭外部编辑提示" + }, "quarantine": { "affordance": "未分配的 CLI", "openLabel": "打开 {{count}} 个未分配的 CLI 对话", @@ -95,7 +99,15 @@ "explanation": "部分消息进入了代理不再继续的路径。", "branchLabel": "分支 {{index}}", "resumeHint": "Claude 将从这里继续", - "emptyBranch": "此分支中没有可见消息" + "emptyBranch": "此分支中没有可见消息", + "bringBack": "恢复这些消息", + "bringingBack": "正在恢复这些消息…", + "recoveryStartedToast": "已将恢复的消息发送给代理", + "recoveryQueuedToast": "恢复消息已加入队列 — 代理正忙;这些消息将接着发送", + "recoveryRoutedToast": "恢复消息已发送至 CLI — 正在等待其出现在对话中", + "recoveryConflictToast": "未发送恢复消息 — 已有另一条消息在队列中", + "recoveryErrorToast": "无法恢复这些消息。请重试。", + "dismissToast": "关闭恢复通知" }, "loadingEarlierMessages": "正在加载早期消息", "cliEmptyTitle": "此工作区通常在终端中运行", diff --git a/packages/web-core/src/i18n/locales/zh-Hans/tasks.json b/packages/web-core/src/i18n/locales/zh-Hans/tasks.json index e00ad346e57..d4dbbf7a717 100644 --- a/packages/web-core/src/i18n/locales/zh-Hans/tasks.json +++ b/packages/web-core/src/i18n/locales/zh-Hans/tasks.json @@ -108,9 +108,6 @@ "copyLogs": "复制日志", "logsCopied": "已复制!" }, - "followUp": { - "queuedMessage": "消息已排队 - 将在当前运行完成时执行" - }, "todoPopup": { "title": "任务", "progress": "{{completed}}/{{total}} 已完成", @@ -386,6 +383,27 @@ "retry": "重试", "showMore": "显示更多", "showLess": "收起", + "queue": { + "queued": "已加入队列 — 正在等待代理", + "pasting": "正在发送至 CLI…", + "pasted": "已加入 CLI 队列 — 尚未提交", + "failed": "发送已暂停", + "sendAgain": "再次发送", + "sendingAgain": "正在发送…", + "heldToast": "已暂缓 — 代理正忙;您的消息已加入队列,将接着发送", + "deliveryFailedToast": "已暂缓 — 发送失败;您的消息仍在队列中。使用“再次发送”立即重试。", + "routedToast": "已发送至 CLI — 正在等待消息出现在对话中", + "retryingToast": "消息已重新加入队列 — 接下来将重试", + "replaceTitle": "要替换队列中的消息吗?", + "replaceUiMessage": "要替换您已排队的消息“{{message}}”吗?", + "replaceRecoveryMessage": "要替换已恢复的 CLI 消息“{{message}}”吗?", + "replaceConfirm": "替换并发送", + "replaceInProgressToast": "该消息已在发送中。请等待发送完成后再发送其他消息。", + "replaceChangedToast": "队列中的消息在替换前发生了变化。请重试。", + "queueErrorToast": "无法将您的消息加入队列。请重试。", + "retryErrorToast": "无法再次发送队列中的消息。请重试。", + "dismissToast": "关闭通知" + }, "actions": { "cancel": "取消", "submitFeedback": "提交反馈", diff --git a/packages/web-core/src/i18n/locales/zh-Hant/common.json b/packages/web-core/src/i18n/locales/zh-Hant/common.json index 1e41e8c42ed..94cd362ca9b 100644 --- a/packages/web-core/src/i18n/locales/zh-Hant/common.json +++ b/packages/web-core/src/i18n/locales/zh-Hant/common.json @@ -70,6 +70,10 @@ "assistant": "Claude", "viaCli": "透過 CLI", "cliSessionActive": "CLI 工作階段使用中", + "foreignWriter": { + "message": "此對話正在應用程式外進行編輯。", + "dismiss": "關閉外部編輯提示" + }, "quarantine": { "affordance": "未分配的 CLI", "openLabel": "開啟 {{count}} 個未分配的 CLI 對話", @@ -95,7 +99,15 @@ "explanation": "部分訊息進入了代理不再繼續的路徑。", "branchLabel": "分支 {{index}}", "resumeHint": "Claude 將從這裡繼續", - "emptyBranch": "此分支中沒有可見訊息" + "emptyBranch": "此分支中沒有可見訊息", + "bringBack": "復原這些訊息", + "bringingBack": "正在復原這些訊息…", + "recoveryStartedToast": "已將復原的訊息傳送給代理", + "recoveryQueuedToast": "復原訊息已加入佇列 — 代理正忙;這些訊息將接著傳送", + "recoveryRoutedToast": "復原訊息已傳送至 CLI — 正在等待訊息出現在對話中", + "recoveryConflictToast": "未傳送復原訊息 — 已有另一則訊息在佇列中", + "recoveryErrorToast": "無法復原這些訊息,請再試一次。", + "dismissToast": "關閉復原通知" }, "loadingEarlierMessages": "正在載入先前的訊息", "cliEmptyTitle": "此工作區通常在終端機中執行", diff --git a/packages/web-core/src/i18n/locales/zh-Hant/tasks.json b/packages/web-core/src/i18n/locales/zh-Hant/tasks.json index 0d6e2d05601..3c1aae6d8e8 100644 --- a/packages/web-core/src/i18n/locales/zh-Hant/tasks.json +++ b/packages/web-core/src/i18n/locales/zh-Hant/tasks.json @@ -108,9 +108,6 @@ "copyLogs": "複製日誌", "logsCopied": "已複製!" }, - "followUp": { - "queuedMessage": "訊息已加入佇列 - 會在目前執行完成後處理" - }, "todoPopup": { "title": "任務", "progress": "{{completed}}/{{total}} 已完成", @@ -386,6 +383,27 @@ "retry": "重試", "showMore": "顯示更多", "showLess": "收起", + "queue": { + "queued": "已加入佇列 — 正在等待代理", + "pasting": "正在傳送至 CLI…", + "pasted": "已加入 CLI 佇列 — 尚未提交", + "failed": "傳送已暫停", + "sendAgain": "再次傳送", + "sendingAgain": "正在傳送…", + "heldToast": "已暫緩 — 代理正忙;您的訊息已加入佇列,將接著傳送", + "deliveryFailedToast": "已暫緩 — 傳送失敗;您的訊息仍在佇列中。使用「再次傳送」立即重試。", + "routedToast": "已傳送至 CLI — 正在等待訊息出現在對話中", + "retryingToast": "訊息已重新加入佇列 — 接下來將重試", + "replaceTitle": "要取代佇列中的訊息嗎?", + "replaceUiMessage": "要取代您已排入佇列的訊息「{{message}}」嗎?", + "replaceRecoveryMessage": "要取代已復原的 CLI 訊息「{{message}}」嗎?", + "replaceConfirm": "取代並傳送", + "replaceInProgressToast": "該訊息已在傳送中。請等待傳送完成後再傳送其他訊息。", + "replaceChangedToast": "佇列中的訊息在取代前發生了變更。請重試。", + "queueErrorToast": "無法將您的訊息加入佇列。請重試。", + "retryErrorToast": "無法再次傳送佇列中的訊息。請重試。", + "dismissToast": "關閉通知" + }, "actions": { "cancel": "取消", "submitFeedback": "提交回饋", From 96bee803c52adfcdb7205d0d3593e0f6a620a8e7 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 22:40:28 +0000 Subject: [PATCH 18/36] fix(executor): notify collab after skipped cleanup --- crates/local-deployment/src/container.rs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/crates/local-deployment/src/container.rs b/crates/local-deployment/src/container.rs index 72587e4ab53..b212e0aa6a1 100644 --- a/crates/local-deployment/src/container.rs +++ b/crates/local-deployment/src/container.rs @@ -690,7 +690,7 @@ impl LocalContainerService { ExecutionProcessStatus::Running ); - let mut already_finalized = false; + let mut skipped_cleanup = false; if success || cleanup_done { // Commit changes (if any) and get feedback about whether changes were made @@ -727,14 +727,14 @@ impl LocalContainerService { "Skipping cleanup script for workspace {} - no changes made by coding agent", ctx.workspace.id ); - - // Manually finalize task since we're bypassing normal execution flow - container.finalize_task(&ctx).await; - already_finalized = true; + skipped_cleanup = true; } } - if !already_finalized && container.should_finalize(&ctx) { + // A no-change coding turn bypasses its configured cleanup action, + // but it still releases the collaboration writer just like the + // normal final action in the chain. + if skipped_cleanup || container.should_finalize(&ctx) { let has_chained_follow_up = ctx .execution_process .executor_action() @@ -764,7 +764,8 @@ impl LocalContainerService { let should_mark_turn_unseen = matches!( ctx.execution_process.run_reason, ExecutionProcessRunReason::CodingAgent - ) && !has_chained_follow_up + ) && !skipped_cleanup + && !has_chained_follow_up && !started_queued_follow_up; if should_mark_turn_unseen From dc7917d420a7c8d9d97137223269272dcf1a7ec4 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 22:40:38 +0000 Subject: [PATCH 19/36] fix(collab): recover busy resume handoffs --- crates/db/src/models/cli_pane_binding.rs | 9 + .../local-deployment/src/paste_transport.rs | 12 +- crates/services/src/services/cli_collab.rs | 533 ++++++++++++++++-- 3 files changed, 503 insertions(+), 51 deletions(-) diff --git a/crates/db/src/models/cli_pane_binding.rs b/crates/db/src/models/cli_pane_binding.rs index f1c2ec16fc7..c17ae31dd37 100644 --- a/crates/db/src/models/cli_pane_binding.rs +++ b/crates/db/src/models/cli_pane_binding.rs @@ -104,6 +104,15 @@ impl CliPaneBinding { .await } + pub async fn list_active(pool: &SqlitePool) -> Result, sqlx::Error> { + let sql = format!( + "SELECT {} FROM cli_pane_bindings \ + WHERE released_at IS NULL ORDER BY created_at ASC", + Self::SELECT_FIELDS + ); + sqlx::query_as::<_, Self>(&sql).fetch_all(pool).await + } + pub async fn bind_discovered_sid( pool: &SqlitePool, id: Uuid, diff --git a/crates/local-deployment/src/paste_transport.rs b/crates/local-deployment/src/paste_transport.rs index bc68c96282b..5aaca14d4a0 100644 --- a/crates/local-deployment/src/paste_transport.rs +++ b/crates/local-deployment/src/paste_transport.rs @@ -18,19 +18,15 @@ impl CliPasteTransport for LocalCliPasteTransport { pty::send_cli_keys(workspace_id, text).await } - async fn pane_alive(&self, workspace_id: Uuid) -> bool { - pty::cli_tmux_session_exists_checked(workspace_id) - .await - .unwrap_or(false) + async fn pane_alive(&self, workspace_id: Uuid) -> anyhow::Result { + Ok(pty::cli_tmux_session_exists_checked(workspace_id).await?) } async fn agent_running(&self, workspace_id: Uuid) -> Option { pty::cli_pane_agent_running(workspace_id, "claude").await } - async fn signal_resume_ready(&self, workspace_id: Uuid, sid: &str) { - if let Err(error) = pty::write_cli_resume_ready_file(workspace_id, sid) { - tracing::warn!(?error, %workspace_id, "failed to signal CLI resume readiness"); - } + async fn signal_resume_ready(&self, workspace_id: Uuid, sid: &str) -> anyhow::Result<()> { + Ok(pty::write_cli_resume_ready_file(workspace_id, sid)?) } } diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index 090e3fd44f4..35701f366ed 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -1,5 +1,5 @@ use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, path::{Path, PathBuf}, sync::{Arc, Weak}, time::Duration, @@ -37,6 +37,8 @@ use super::{ }; const DRAIN_INTERVAL: Duration = Duration::from_secs(1); +const RESUME_SIGNAL_ATTEMPTS: u32 = 3; +const RESUME_SIGNAL_RETRY_DELAY: Duration = Duration::from_millis(100); const PASTING_STARTUP_GRACE: ChronoDuration = ChronoDuration::seconds(5); const PASTE_ACK_HARD_CAP: ChronoDuration = ChronoDuration::minutes(15); @@ -86,9 +88,9 @@ pub trait CliWriterProbe: Send + Sync { #[async_trait] pub trait CliPasteTransport: Send + Sync { async fn paste_and_submit(&self, workspace_id: Uuid, text: &str) -> bool; - async fn pane_alive(&self, workspace_id: Uuid) -> bool; + async fn pane_alive(&self, workspace_id: Uuid) -> AnyhowResult; async fn agent_running(&self, workspace_id: Uuid) -> Option; - async fn signal_resume_ready(&self, workspace_id: Uuid, sid: &str); + async fn signal_resume_ready(&self, workspace_id: Uuid, sid: &str) -> AnyhowResult<()>; } /// Deployment-owned executor bridge. The trait lives in services so @@ -132,6 +134,12 @@ pub enum CliCollabError { Serialize(#[from] serde_json::Error), #[error("session {0} has no workspace")] WorkspaceMissing(Uuid), + #[error("CLI transport failed for workspace {workspace_id}: {source}")] + Transport { + workspace_id: Uuid, + #[source] + source: anyhow::Error, + }, } pub struct CliCollabService { @@ -141,6 +149,7 @@ pub struct CliCollabService { dispatcher: Arc, ingest: Option>, session_locks: Mutex>>>, + resume_signaled_bindings: Mutex>, notify: Notify, routing_disabled: bool, shutdown: CancellationToken, @@ -162,6 +171,7 @@ impl CliCollabService { dispatcher, ingest, session_locks: Mutex::new(HashMap::new()), + resume_signaled_bindings: Mutex::new(HashSet::new()), notify: Notify::new(), routing_disabled: std::env::var_os("DISABLE_CLI_COLLAB_ROUTING").is_some(), shutdown, @@ -257,8 +267,29 @@ impl CliCollabService { } match report.agent_running { None => return WriterLease::Busy, - Some(false) => return WriterLease::Free, - Some(true) => {} + Some(false) => { + let handoff_pending = match binding.as_ref() { + Some(binding) => self + .resume_signaled_bindings + .lock() + .await + .contains(&binding.id), + None => false, + }; + return if handoff_pending { + WriterLease::CliAmbiguous + } else { + WriterLease::Free + }; + } + Some(true) => { + if let Some(binding) = binding.as_ref() { + self.resume_signaled_bindings + .lock() + .await + .remove(&binding.id); + } + } } let Some(binding) = binding.filter(|binding| binding.session_id == session.id) else { @@ -603,54 +634,169 @@ impl CliCollabService { } pub async fn on_executor_finished(&self, session_id: Uuid) -> Result { + let lock = self.session_lock(session_id).await; + let guard = lock.lock().await; let session = Session::find_by_id(&self.db.pool, session_id) .await? .ok_or(CliCollabError::WorkspaceMissing(session_id))?; - let binding = match CliPaneBinding::find_active_for_session(&self.db.pool, session_id).await + match ExecutionProcess::has_running_coding_agent_for_session(&self.db.pool, session_id) + .await { - Ok(binding) => binding, - Err(error) => { - tracing::warn!(?error, %session_id, "finish hook pane lookup failed closed"); + Ok(true) => { + tracing::debug!( + %session_id, + "CLI resume handoff deferred while another executor is running" + ); return Ok(false); } - }; - if let Some(binding) = binding - && self.transport.pane_alive(session.workspace_id).await - { - let sid = match binding.claude_session_id.as_ref() { - Some(sid) => Some(sid.clone()), - None => match self.expected_sid(session_id).await { - Ok(sid) => sid, - Err(error) => { - tracing::warn!(?error, %session_id, "finish hook sid lookup failed closed"); - return Ok(false); - } - }, - }; - let Some(sid) = sid else { - return Ok(false); - }; - if binding.bound_via == db::models::cli_pane_binding::CliPaneBoundVia::CliFresh - && binding.claude_session_id.is_none() - && let Err(error) = - CliPaneBinding::bind_discovered_sid(&self.db.pool, binding.id, &sid).await - { - tracing::warn!(?error, %session_id, "finish hook pane sid update failed closed"); - return Ok(false); + Ok(false) => {} + Err(error) => { + tracing::warn!(?error, %session_id, "finish hook executor probe failed closed"); + return Err(error.into()); } - self.transport - .signal_resume_ready(session.workspace_id, &sid) - .await; + } + if let Some(binding) = + CliPaneBinding::find_active_for_session(&self.db.pool, session_id).await? + && self + .hold_or_resume_bound_pane_locked(&session, binding) + .await? + { // Give the 1 s bootstrap poll a chance to exec the resumed TUI; // the durable queue remains untouched during this transition. self.notify.notify_one(); return Ok(false); } + drop(guard); let started = self.drain_session(session_id).await?; self.notify.notify_one(); Ok(started) } + async fn hold_or_resume_bound_pane_locked( + &self, + session: &Session, + binding: CliPaneBinding, + ) -> Result { + let pane_alive = self + .transport + .pane_alive(session.workspace_id) + .await + .map_err(|source| CliCollabError::Transport { + workspace_id: session.workspace_id, + source, + })?; + if !pane_alive { + CliPaneBinding::release(&self.db.pool, binding.id).await?; + self.resume_signaled_bindings + .lock() + .await + .remove(&binding.id); + return Ok(false); + } + match self.transport.agent_running(session.workspace_id).await { + Some(true) => { + self.resume_signaled_bindings + .lock() + .await + .remove(&binding.id); + return Ok(true); + } + None => { + tracing::warn!( + session_id = %session.id, + workspace_id = %session.workspace_id, + "busy CLI pane agent probe failed closed; resume handoff will retry" + ); + return Ok(true); + } + Some(false) => {} + } + if self + .resume_signaled_bindings + .lock() + .await + .contains(&binding.id) + { + return Ok(true); + } + + let sid = match binding.claude_session_id.as_ref() { + Some(sid) => Some(sid.clone()), + None => self.expected_sid(session.id).await?, + }; + let Some(sid) = sid else { + tracing::warn!( + session_id = %session.id, + workspace_id = %session.workspace_id, + "busy CLI pane has no resume sid yet; resume handoff will retry" + ); + return Ok(true); + }; + if binding.bound_via == db::models::cli_pane_binding::CliPaneBoundVia::CliFresh + && binding.claude_session_id.is_none() + && !CliPaneBinding::bind_discovered_sid(&self.db.pool, binding.id, &sid).await? + { + tracing::warn!( + session_id = %session.id, + binding_id = %binding.id, + "busy CLI pane sid binding changed before resume handoff; retrying from fresh state" + ); + return Ok(true); + } + + self.signal_resume_ready_with_retry(session.workspace_id, &sid) + .await?; + self.resume_signaled_bindings + .lock() + .await + .insert(binding.id); + tracing::info!( + session_id = %session.id, + workspace_id = %session.workspace_id, + binding_id = %binding.id, + claude_session_id = %sid, + "signaled busy CLI pane to resume" + ); + Ok(true) + } + + async fn signal_resume_ready_with_retry( + &self, + workspace_id: Uuid, + sid: &str, + ) -> Result<(), CliCollabError> { + let mut last_error = None; + for attempt in 1..=RESUME_SIGNAL_ATTEMPTS { + match self.transport.signal_resume_ready(workspace_id, sid).await { + Ok(()) => return Ok(()), + Err(error) => { + tracing::warn!( + ?error, + %workspace_id, + attempt, + max_attempts = RESUME_SIGNAL_ATTEMPTS, + "failed to write CLI resume-ready signal" + ); + last_error = Some(error); + if attempt < RESUME_SIGNAL_ATTEMPTS { + tokio::time::sleep(RESUME_SIGNAL_RETRY_DELAY).await; + } + } + } + } + let source = last_error.expect("resume signaling always makes at least one attempt"); + tracing::error!( + ?source, + %workspace_id, + attempts = RESUME_SIGNAL_ATTEMPTS, + "CLI resume-ready signal exhausted retries; busy pane remains blocked" + ); + Err(CliCollabError::Transport { + workspace_id, + source, + }) + } + async fn drain_session(&self, session_id: Uuid) -> Result { if self.routing_disabled { return Ok(false); @@ -844,7 +990,99 @@ impl CliCollabService { } } + async fn reconcile_waiting_panes(&self) { + let bindings = match CliPaneBinding::list_active(&self.db.pool).await { + Ok(bindings) => bindings, + Err(error) => { + tracing::warn!(?error, "failed to scan active CLI pane bindings"); + return; + } + }; + let active_ids: HashSet<_> = bindings.iter().map(|binding| binding.id).collect(); + self.resume_signaled_bindings + .lock() + .await + .retain(|id| active_ids.contains(id)); + + for observed_binding in bindings { + let session_id = observed_binding.session_id; + let lock = self.session_lock(session_id).await; + let _guard = lock.lock().await; + let binding = + match CliPaneBinding::find_active_for_session(&self.db.pool, session_id).await { + Ok(Some(binding)) if binding.id == observed_binding.id => binding, + Ok(_) => continue, + Err(error) => { + tracing::warn!( + ?error, + %session_id, + "failed to refresh CLI pane binding during resume reconciliation" + ); + continue; + } + }; + match ExecutionProcess::has_running_coding_agent_for_session(&self.db.pool, session_id) + .await + { + Ok(true) => continue, + Ok(false) => {} + Err(error) => { + tracing::warn!( + ?error, + %session_id, + "resume reconciliation executor probe failed closed" + ); + continue; + } + } + let processes = + match ExecutionProcess::find_by_session_id(&self.db.pool, session_id, true).await { + Ok(processes) => processes, + Err(error) => { + tracing::warn!( + ?error, + %session_id, + "failed to load executor release evidence for CLI resume" + ); + continue; + } + }; + let writer_released_after_binding = processes.iter().any(|process| { + process.run_reason == ExecutionProcessRunReason::CodingAgent + && process + .completed_at + .is_some_and(|completed_at| completed_at >= binding.created_at) + }); + if !writer_released_after_binding { + continue; + } + let session = match Session::find_by_id(&self.db.pool, session_id).await { + Ok(Some(session)) => session, + Ok(None) => continue, + Err(error) => { + tracing::warn!( + ?error, + %session_id, + "failed to load session during CLI resume reconciliation" + ); + continue; + } + }; + if let Err(error) = self + .hold_or_resume_bound_pane_locked(&session, binding) + .await + { + tracing::error!( + ?error, + %session_id, + "failed to reconcile busy CLI resume handoff; will retry" + ); + } + } + } + async fn drain_all(&self) { + self.reconcile_waiting_panes().await; self.reconcile_delivery_state().await; if self.routing_disabled { return; @@ -932,7 +1170,9 @@ mod tests { claude_session_link::ClaudeSessionLink, cli_native_file::{CliNativeFile, RegisterCliNativeFile}, cli_pane_binding::{CliPaneBinding, CliPaneBoundVia}, - execution_process::{CreateExecutionProcess, ExecutionProcessRunReason}, + execution_process::{ + CreateExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus, + }, session::CreateSession, workspace::CreateWorkspace, }; @@ -969,15 +1209,17 @@ mod tests { true } - async fn pane_alive(&self, _workspace_id: Uuid) -> bool { - true + async fn pane_alive(&self, _workspace_id: Uuid) -> AnyhowResult { + Ok(true) } async fn agent_running(&self, _workspace_id: Uuid) -> Option { Some(true) } - async fn signal_resume_ready(&self, _workspace_id: Uuid, _sid: &str) {} + async fn signal_resume_ready(&self, _workspace_id: Uuid, _sid: &str) -> AnyhowResult<()> { + Ok(()) + } } #[derive(Debug, Default)] @@ -1007,15 +1249,52 @@ mod tests { self.paste_succeeds } - async fn pane_alive(&self, _workspace_id: Uuid) -> bool { - true + async fn pane_alive(&self, _workspace_id: Uuid) -> AnyhowResult { + Ok(true) } async fn agent_running(&self, _workspace_id: Uuid) -> Option { Some(true) } - async fn signal_resume_ready(&self, _workspace_id: Uuid, _sid: &str) {} + async fn signal_resume_ready(&self, _workspace_id: Uuid, _sid: &str) -> AnyhowResult<()> { + Ok(()) + } + } + + #[derive(Debug, Default)] + struct ResumeTransportState { + signals: Vec<(Uuid, String)>, + failures_remaining: u32, + } + + struct ResumeRecordingTransport { + state: Arc>, + } + + #[async_trait] + impl CliPasteTransport for ResumeRecordingTransport { + async fn paste_and_submit(&self, _workspace_id: Uuid, _text: &str) -> bool { + true + } + + async fn pane_alive(&self, _workspace_id: Uuid) -> AnyhowResult { + Ok(true) + } + + async fn agent_running(&self, _workspace_id: Uuid) -> Option { + Some(false) + } + + async fn signal_resume_ready(&self, workspace_id: Uuid, sid: &str) -> AnyhowResult<()> { + let mut state = self.state.lock().unwrap(); + state.signals.push((workspace_id, sid.to_string())); + if state.failures_remaining > 0 { + state.failures_remaining -= 1; + anyhow::bail!("intentional resume signal failure"); + } + Ok(()) + } } struct NeverDispatcher; @@ -1143,6 +1422,7 @@ mod tests { dispatcher, ingest: None, session_locks: Mutex::new(HashMap::new()), + resume_signaled_bindings: Mutex::new(HashSet::new()), notify: Notify::new(), routing_disabled: false, shutdown: CancellationToken::new(), @@ -1347,6 +1627,173 @@ mod tests { assert!(transport_state.lock().unwrap().pasted_prompts.is_empty()); } + #[tokio::test] + async fn finish_hook_waits_for_auto_drained_executor_before_resuming_busy_pane() { + let (db, workspace, session) = fixture().await; + let sid = "24242424-2424-4242-8242-242424242424"; + let first = create_running_executor(&db, session.id, "blocking writer").await; + bind_confirmed_cli(&db, &workspace, &session, sid).await; + ExecutionProcess::update_completion( + &db.pool, + first.id, + ExecutionProcessStatus::Completed, + Some(0), + ) + .await + .unwrap(); + let second = create_running_executor(&db, session.id, "auto-drained follow-up").await; + let transport_state = Arc::new(StdMutex::new(ResumeTransportState::default())); + let transport = Arc::new(ResumeRecordingTransport { + state: transport_state.clone(), + }); + let (service, probe) = service_with_components( + db.clone(), + report(false, Some(false), SidEvidence::Unknown), + transport, + Arc::new(NeverDispatcher), + ); + + assert!(!service.on_executor_finished(session.id).await.unwrap()); + assert!( + transport_state.lock().unwrap().signals.is_empty(), + "a prior finish hook must not resume the pane over a newer executor" + ); + + ExecutionProcess::update_completion( + &db.pool, + second.id, + ExecutionProcessStatus::Completed, + Some(0), + ) + .await + .unwrap(); + assert!(!service.on_executor_finished(session.id).await.unwrap()); + assert_eq!( + transport_state.lock().unwrap().signals, + [(workspace.id, sid.to_string())] + ); + + *probe.0.lock().unwrap() = report( + true, + Some(true), + SidEvidence::ConfirmedResume(sid.to_string()), + ); + assert_eq!( + service.derive_lease(&session).await, + WriterLease::Cli { + claude_session_id: Some(sid.to_string()) + } + ); + *probe.0.lock().unwrap() = report(true, Some(false), SidEvidence::Unknown); + assert_eq!(service.derive_lease(&session).await, WriterLease::Free); + } + + #[tokio::test] + async fn drain_recovers_busy_resume_pane_when_finish_hook_was_missed() { + let (db, workspace, session) = fixture().await; + let sid = "25252525-2525-4252-8252-252525252525"; + let execution = create_running_executor(&db, session.id, "blocking writer").await; + bind_confirmed_cli(&db, &workspace, &session, sid).await; + ExecutionProcess::update_completion( + &db.pool, + execution.id, + ExecutionProcessStatus::Completed, + Some(0), + ) + .await + .unwrap(); + let transport_state = Arc::new(StdMutex::new(ResumeTransportState::default())); + let transport = Arc::new(ResumeRecordingTransport { + state: transport_state.clone(), + }); + let (service, _) = service_with_components( + db, + report(false, Some(false), SidEvidence::Unknown), + transport, + Arc::new(NeverDispatcher), + ); + + service.drain_all().await; + + assert_eq!( + transport_state.lock().unwrap().signals, + [(workspace.id, sid.to_string())] + ); + } + + #[tokio::test] + async fn resume_ready_signal_retries_transient_failures() { + let (db, workspace, session) = fixture().await; + let sid = "26262626-2626-4262-8262-262626262626"; + let execution = create_running_executor(&db, session.id, "blocking writer").await; + bind_confirmed_cli(&db, &workspace, &session, sid).await; + ExecutionProcess::update_completion( + &db.pool, + execution.id, + ExecutionProcessStatus::Completed, + Some(0), + ) + .await + .unwrap(); + let transport_state = Arc::new(StdMutex::new(ResumeTransportState { + signals: Vec::new(), + failures_remaining: 2, + })); + let transport = Arc::new(ResumeRecordingTransport { + state: transport_state.clone(), + }); + let (service, _) = service_with_components( + db, + report(false, Some(false), SidEvidence::Unknown), + transport, + Arc::new(NeverDispatcher), + ); + + assert!(!service.on_executor_finished(session.id).await.unwrap()); + + assert_eq!( + transport_state.lock().unwrap().signals, + vec![(workspace.id, sid.to_string()); 3] + ); + } + + #[tokio::test] + async fn resume_ready_signal_exhaustion_is_returned_to_finish_hook() { + let (db, workspace, session) = fixture().await; + let sid = "27272727-2727-4272-8272-272727272727"; + let execution = create_running_executor(&db, session.id, "blocking writer").await; + bind_confirmed_cli(&db, &workspace, &session, sid).await; + ExecutionProcess::update_completion( + &db.pool, + execution.id, + ExecutionProcessStatus::Completed, + Some(0), + ) + .await + .unwrap(); + let transport_state = Arc::new(StdMutex::new(ResumeTransportState { + signals: Vec::new(), + failures_remaining: RESUME_SIGNAL_ATTEMPTS, + })); + let transport = Arc::new(ResumeRecordingTransport { + state: transport_state.clone(), + }); + let (service, _) = service_with_components( + db, + report(false, Some(false), SidEvidence::Unknown), + transport, + Arc::new(NeverDispatcher), + ); + + let error = service.on_executor_finished(session.id).await.unwrap_err(); + + assert!(matches!(error, CliCollabError::Transport { .. })); + assert_eq!( + transport_state.lock().unwrap().signals, + vec![(workspace.id, sid.to_string()); RESUME_SIGNAL_ATTEMPTS as usize] + ); + } + #[tokio::test] async fn dispatch_gate_routes_confirmed_cli_through_pasting_to_pasted() { let (db, workspace, session) = fixture().await; From d7eceafbe7bb6e5dbed5f88a74109e89ef0fc2a7 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 23:38:49 +0000 Subject: [PATCH 20/36] fix(collab): trust live resume evidence --- crates/local-deployment/src/writer_probe.rs | 87 +++++++++++++++------ crates/services/src/services/cli_collab.rs | 26 ++++++ 2 files changed, 89 insertions(+), 24 deletions(-) diff --git a/crates/local-deployment/src/writer_probe.rs b/crates/local-deployment/src/writer_probe.rs index a7095a52225..0b0e2559552 100644 --- a/crates/local-deployment/src/writer_probe.rs +++ b/crates/local-deployment/src/writer_probe.rs @@ -5,7 +5,7 @@ use db::{DBService, models::cli_pane_binding::CliPaneBinding}; use services::services::cli_collab::{CliWriterProbe, ProbeReport, SidEvidence}; use uuid::Uuid; -use crate::pty::{cli_pane_agent_processes, cli_tmux_session_exists_checked}; +use crate::pty::{CliPaneAgentProcess, cli_pane_agent_processes, cli_tmux_session_exists_checked}; #[derive(Clone)] pub struct LocalCliWriterProbe { @@ -57,6 +57,28 @@ fn resume_evidence(cmdlines: &[String]) -> SidEvidence { } } +fn live_process_report( + processes: &[CliPaneAgentProcess], + only_active_claude_in_cwd: Option, +) -> ProbeReport { + let agent_running = !processes.is_empty(); + let cmdlines: Vec<_> = processes + .iter() + .map(|process| process.cmdline.clone()) + .collect(); + ProbeReport { + pane_session_exists: true, + agent_running: Some(agent_running), + sid_evidence: if agent_running { + resume_evidence(&cmdlines) + } else { + SidEvidence::Unknown + }, + probe_failed: false, + only_active_claude_in_cwd, + } +} + #[cfg(target_os = "linux")] fn only_active_claude_in_cwd(effective_dir: &Path, pane_pids: &HashSet) -> Option { let effective_dir = effective_dir @@ -118,7 +140,7 @@ impl CliWriterProbe for LocalCliWriterProbe { &self, workspace_id: Uuid, effective_dir: &Path, - expected_sid: Option<&str>, + _expected_sid: Option<&str>, ) -> ProbeReport { let binding = match CliPaneBinding::find_active_for_workspace(&self.db.pool, workspace_id).await { @@ -156,33 +178,15 @@ impl CliWriterProbe for LocalCliWriterProbe { None => return ProbeReport::failed(), }; let agent_running = !processes.is_empty(); - let cmdlines: Vec<_> = processes - .iter() - .map(|process| process.cmdline.clone()) - .collect(); - let mut sid_evidence = if agent_running { - resume_evidence(&cmdlines) - } else { - SidEvidence::Unknown - }; - if agent_running - && let (Some(expected), Some(binding)) = (expected_sid, binding.as_ref()) - && binding.claude_session_id.as_deref() == Some(expected) - { - sid_evidence = SidEvidence::ConfirmedResume(expected.to_string()); - } let pane_pids = processes.iter().map(|process| process.pid).collect(); - ProbeReport { - pane_session_exists: true, - agent_running: Some(agent_running), - sid_evidence, - probe_failed: false, - only_active_claude_in_cwd: if agent_running { + live_process_report( + &processes, + if agent_running { only_active_claude_in_cwd(effective_dir, &pane_pids) } else { Some(false) }, - } + ) } } @@ -208,4 +212,39 @@ mod tests { SidEvidence::NoResumeArg ); } + + #[test] + fn live_probe_report_never_fabricates_database_resume_evidence() { + let expected = "11111111-1111-4111-8111-111111111111"; + let observed = "22222222-2222-4222-8222-222222222222"; + + let mismatched = live_process_report( + &[CliPaneAgentProcess { + pid: 42, + cmdline: format!("claude --resume {observed}"), + }], + None, + ); + assert_eq!( + mismatched.sid_evidence, + SidEvidence::ConfirmedResume(observed.to_string()) + ); + assert_ne!( + mismatched.sid_evidence, + SidEvidence::ConfirmedResume(expected.to_string()) + ); + + let no_resume = live_process_report( + &[CliPaneAgentProcess { + pid: 43, + cmdline: "claude --model opus".to_string(), + }], + None, + ); + assert_eq!(no_resume.sid_evidence, SidEvidence::NoResumeArg); + assert_ne!( + no_resume.sid_evidence, + SidEvidence::ConfirmedResume(expected.to_string()) + ); + } } diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index 35701f366ed..b9995d08a21 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -1577,6 +1577,32 @@ mod tests { assert_eq!(service.derive_lease(&session).await, WriterLease::Executor); } + #[tokio::test] + async fn stale_database_sid_never_overrides_live_resume_evidence() { + let (db, workspace, session) = fixture().await; + let expected = "11111111-1111-4111-8111-111111111111"; + bind_confirmed_cli(&db, &workspace, &session, expected).await; + let (service, probe) = service( + db, + report( + true, + Some(true), + SidEvidence::ConfirmedResume("22222222-2222-4222-8222-222222222222".to_string()), + ), + ); + + assert_eq!( + service.derive_lease(&session).await, + WriterLease::CliAmbiguous + ); + + *probe.0.lock().unwrap() = report(true, Some(true), SidEvidence::NoResumeArg); + assert_eq!( + service.derive_lease(&session).await, + WriterLease::CliAmbiguous + ); + } + #[tokio::test] async fn lease_derivation_fails_closed_on_database_and_probe_errors() { let (db, _workspace, session) = fixture().await; From a145c4f37f1c91d75cf48e099df5bc29e5317733 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 23:43:55 +0000 Subject: [PATCH 21/36] fix(collab): fence queued executor dispatches --- ...43432d586a625417c0f438a133d0578e4f2b.json} | 6 +- ...6b1821c406b8063d2137314d9920914bb2a2e.json | 12 ++ ...ae1846543d178054863b3720dbd11fb8b8ba2.json | 12 ++ ...dda870c127ddf6317e95e82ebb9233739953c.json | 12 -- ...c3579ceca4fc4137d88b5626977420aaef56e.json | 12 -- ...02a999e480568a9840c520b76394638a8df0.json} | 4 +- ...6395aff01a59d9e9651d5fb968c60f894711c.json | 12 -- ...0f5a0f118b0fe5919979c6cdb85589b4d302.json} | 4 +- ...8adada965748dad9aa0ed6c08a812827c273b.json | 12 ++ ...1100000_add_queue_executor_claim_owner.sql | 5 + .../db/src/models/session_queued_message.rs | 144 ++++++++++++++--- .../claude_transcript_ingest/tests.rs | 4 +- crates/services/src/services/cli_collab.rs | 148 ++++++++++++++++-- .../services/src/services/queued_message.rs | 18 ++- 14 files changed, 326 insertions(+), 79 deletions(-) rename crates/db/.sqlx/{query-4f7ac2ea943df6facd5d22bc88f6b348cc0cc61c2a482410494dd3411b3113ce.json => query-1f6dd1912ce05b608790901c09ba43432d586a625417c0f438a133d0578e4f2b.json} (67%) create mode 100644 crates/db/.sqlx/query-23088d611e12e7bf78b08417a886b1821c406b8063d2137314d9920914bb2a2e.json create mode 100644 crates/db/.sqlx/query-532d942893f3449104e53c413dbae1846543d178054863b3720dbd11fb8b8ba2.json delete mode 100644 crates/db/.sqlx/query-62b1de1e117e40ede73b8f317a2dda870c127ddf6317e95e82ebb9233739953c.json delete mode 100644 crates/db/.sqlx/query-6f7cfc58051988c9d05003dba3fc3579ceca4fc4137d88b5626977420aaef56e.json rename crates/db/.sqlx/{query-571ac8dbfea3f9fe034b21df5082a58f6daed273419595b9f3f17b37019446ad.json => query-7f72352f40bc3e40e74c4cd4f29602a999e480568a9840c520b76394638a8df0.json} (53%) delete mode 100644 crates/db/.sqlx/query-accca376c4344d10a3a078643b46395aff01a59d9e9651d5fb968c60f894711c.json rename crates/db/.sqlx/{query-6c3bad4405429bf3b9ec81b7c6c1c73e1d9eebf058e7447bea9d43bb9bb91caf.json => query-ce1b3af7d1bc00ea52648cc2649e0f5a0f118b0fe5919979c6cdb85589b4d302.json} (70%) create mode 100644 crates/db/.sqlx/query-e45b7815888b93d10ae9dd19bbe8adada965748dad9aa0ed6c08a812827c273b.json create mode 100644 crates/db/migrations/20260721100000_add_queue_executor_claim_owner.sql diff --git a/crates/db/.sqlx/query-4f7ac2ea943df6facd5d22bc88f6b348cc0cc61c2a482410494dd3411b3113ce.json b/crates/db/.sqlx/query-1f6dd1912ce05b608790901c09ba43432d586a625417c0f438a133d0578e4f2b.json similarity index 67% rename from crates/db/.sqlx/query-4f7ac2ea943df6facd5d22bc88f6b348cc0cc61c2a482410494dd3411b3113ce.json rename to crates/db/.sqlx/query-1f6dd1912ce05b608790901c09ba43432d586a625417c0f438a133d0578e4f2b.json index 50a07f7c4b1..eb6f9cdbbbb 100644 --- a/crates/db/.sqlx/query-4f7ac2ea943df6facd5d22bc88f6b348cc0cc61c2a482410494dd3411b3113ce.json +++ b/crates/db/.sqlx/query-1f6dd1912ce05b608790901c09ba43432d586a625417c0f438a133d0578e4f2b.json @@ -1,12 +1,12 @@ { "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'consumed',\n updated_at = datetime('now', 'subsec')\n WHERE id = $1 AND state = 'pasting'", + "query": "UPDATE session_queued_messages SET\n state = 'consumed',\n updated_at = datetime('now', 'subsec')\n WHERE id = $1 AND state = 'pasting'\n AND executor_claim_owner = $2", "describe": { "columns": [], "parameters": { - "Right": 1 + "Right": 2 }, "nullable": [] }, - "hash": "4f7ac2ea943df6facd5d22bc88f6b348cc0cc61c2a482410494dd3411b3113ce" + "hash": "1f6dd1912ce05b608790901c09ba43432d586a625417c0f438a133d0578e4f2b" } diff --git a/crates/db/.sqlx/query-23088d611e12e7bf78b08417a886b1821c406b8063d2137314d9920914bb2a2e.json b/crates/db/.sqlx/query-23088d611e12e7bf78b08417a886b1821c406b8063d2137314d9920914bb2a2e.json new file mode 100644 index 00000000000..62eb69ebb1c --- /dev/null +++ b/crates/db/.sqlx/query-23088d611e12e7bf78b08417a886b1821c406b8063d2137314d9920914bb2a2e.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'pasting',\n claude_session_id = $1,\n executor_claim_owner = $2,\n pasted_at = CASE WHEN $2 IS NULL\n THEN datetime('now', 'subsec')\n ELSE NULL END,\n failure_reason = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $3 AND state = 'queued'", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "23088d611e12e7bf78b08417a886b1821c406b8063d2137314d9920914bb2a2e" +} diff --git a/crates/db/.sqlx/query-532d942893f3449104e53c413dbae1846543d178054863b3720dbd11fb8b8ba2.json b/crates/db/.sqlx/query-532d942893f3449104e53c413dbae1846543d178054863b3720dbd11fb8b8ba2.json new file mode 100644 index 00000000000..158006ee1e7 --- /dev/null +++ b/crates/db/.sqlx/query-532d942893f3449104e53c413dbae1846543d178054863b3720dbd11fb8b8ba2.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = $1,\n executor_claim_owner = NULL,\n claude_session_id = CASE\n WHEN state = 'pasted' THEN claude_session_id\n ELSE NULL\n END,\n pasted_at = CASE\n WHEN state = 'pasted' THEN pasted_at\n ELSE NULL\n END,\n acked_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $2 AND state IN ('pasting', 'pasted')", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "532d942893f3449104e53c413dbae1846543d178054863b3720dbd11fb8b8ba2" +} diff --git a/crates/db/.sqlx/query-62b1de1e117e40ede73b8f317a2dda870c127ddf6317e95e82ebb9233739953c.json b/crates/db/.sqlx/query-62b1de1e117e40ede73b8f317a2dda870c127ddf6317e95e82ebb9233739953c.json deleted file mode 100644 index c211595cf2f..00000000000 --- a/crates/db/.sqlx/query-62b1de1e117e40ede73b8f317a2dda870c127ddf6317e95e82ebb9233739953c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = 'delivery interrupted; queued for retry',\n claude_session_id = NULL,\n pasted_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE state = 'pasting'\n AND julianday(updated_at) <= julianday($1)", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "62b1de1e117e40ede73b8f317a2dda870c127ddf6317e95e82ebb9233739953c" -} diff --git a/crates/db/.sqlx/query-6f7cfc58051988c9d05003dba3fc3579ceca4fc4137d88b5626977420aaef56e.json b/crates/db/.sqlx/query-6f7cfc58051988c9d05003dba3fc3579ceca4fc4137d88b5626977420aaef56e.json deleted file mode 100644 index 06f9f0bac90..00000000000 --- a/crates/db/.sqlx/query-6f7cfc58051988c9d05003dba3fc3579ceca4fc4137d88b5626977420aaef56e.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'pasting',\n claude_session_id = $1,\n pasted_at = datetime('now', 'subsec'),\n failure_reason = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $2 AND state = 'queued'", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "6f7cfc58051988c9d05003dba3fc3579ceca4fc4137d88b5626977420aaef56e" -} diff --git a/crates/db/.sqlx/query-571ac8dbfea3f9fe034b21df5082a58f6daed273419595b9f3f17b37019446ad.json b/crates/db/.sqlx/query-7f72352f40bc3e40e74c4cd4f29602a999e480568a9840c520b76394638a8df0.json similarity index 53% rename from crates/db/.sqlx/query-571ac8dbfea3f9fe034b21df5082a58f6daed273419595b9f3f17b37019446ad.json rename to crates/db/.sqlx/query-7f72352f40bc3e40e74c4cd4f29602a999e480568a9840c520b76394638a8df0.json index f69279ce9a9..f38a6c6b773 100644 --- a/crates/db/.sqlx/query-571ac8dbfea3f9fe034b21df5082a58f6daed273419595b9f3f17b37019446ad.json +++ b/crates/db/.sqlx/query-7f72352f40bc3e40e74c4cd4f29602a999e480568a9840c520b76394638a8df0.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n prompt = $1,\n executor_config = $2,\n source = $3,\n failure_reason = NULL,\n claude_session_id = NULL,\n pasted_at = NULL,\n acked_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $4 AND state = 'queued'", + "query": "UPDATE session_queued_messages SET\n prompt = $1,\n executor_config = $2,\n source = $3,\n failure_reason = NULL,\n claude_session_id = NULL,\n executor_claim_owner = NULL,\n pasted_at = NULL,\n acked_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $4 AND state = 'queued'", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "571ac8dbfea3f9fe034b21df5082a58f6daed273419595b9f3f17b37019446ad" + "hash": "7f72352f40bc3e40e74c4cd4f29602a999e480568a9840c520b76394638a8df0" } diff --git a/crates/db/.sqlx/query-accca376c4344d10a3a078643b46395aff01a59d9e9651d5fb968c60f894711c.json b/crates/db/.sqlx/query-accca376c4344d10a3a078643b46395aff01a59d9e9651d5fb968c60f894711c.json deleted file mode 100644 index e2780519abc..00000000000 --- a/crates/db/.sqlx/query-accca376c4344d10a3a078643b46395aff01a59d9e9651d5fb968c60f894711c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = $1,\n claude_session_id = CASE\n WHEN state = 'pasted' THEN claude_session_id\n ELSE NULL\n END,\n pasted_at = CASE\n WHEN state = 'pasted' THEN pasted_at\n ELSE NULL\n END,\n acked_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $2 AND state IN ('pasting', 'pasted')", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "accca376c4344d10a3a078643b46395aff01a59d9e9651d5fb968c60f894711c" -} diff --git a/crates/db/.sqlx/query-6c3bad4405429bf3b9ec81b7c6c1c73e1d9eebf058e7447bea9d43bb9bb91caf.json b/crates/db/.sqlx/query-ce1b3af7d1bc00ea52648cc2649e0f5a0f118b0fe5919979c6cdb85589b4d302.json similarity index 70% rename from crates/db/.sqlx/query-6c3bad4405429bf3b9ec81b7c6c1c73e1d9eebf058e7447bea9d43bb9bb91caf.json rename to crates/db/.sqlx/query-ce1b3af7d1bc00ea52648cc2649e0f5a0f118b0fe5919979c6cdb85589b4d302.json index 8ef74c5b147..bde66ee4e37 100644 --- a/crates/db/.sqlx/query-6c3bad4405429bf3b9ec81b7c6c1c73e1d9eebf058e7447bea9d43bb9bb91caf.json +++ b/crates/db/.sqlx/query-ce1b3af7d1bc00ea52648cc2649e0f5a0f118b0fe5919979c6cdb85589b4d302.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'pasted',\n updated_at = datetime('now', 'subsec')\n WHERE id = $1 AND state = 'pasting'", + "query": "UPDATE session_queued_messages SET\n state = 'pasted',\n updated_at = datetime('now', 'subsec')\n WHERE id = $1 AND state = 'pasting'\n AND executor_claim_owner IS NULL", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "6c3bad4405429bf3b9ec81b7c6c1c73e1d9eebf058e7447bea9d43bb9bb91caf" + "hash": "ce1b3af7d1bc00ea52648cc2649e0f5a0f118b0fe5919979c6cdb85589b4d302" } diff --git a/crates/db/.sqlx/query-e45b7815888b93d10ae9dd19bbe8adada965748dad9aa0ed6c08a812827c273b.json b/crates/db/.sqlx/query-e45b7815888b93d10ae9dd19bbe8adada965748dad9aa0ed6c08a812827c273b.json new file mode 100644 index 00000000000..819fc65e4c0 --- /dev/null +++ b/crates/db/.sqlx/query-e45b7815888b93d10ae9dd19bbe8adada965748dad9aa0ed6c08a812827c273b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = 'delivery interrupted; queued for retry',\n claude_session_id = NULL,\n executor_claim_owner = NULL,\n pasted_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE state = 'pasting'\n AND (\n (executor_claim_owner IS NULL\n AND julianday(updated_at) <= julianday($1))\n OR (executor_claim_owner IS NOT NULL\n AND ($2 IS NULL OR executor_claim_owner != $2))\n )", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "e45b7815888b93d10ae9dd19bbe8adada965748dad9aa0ed6c08a812827c273b" +} diff --git a/crates/db/migrations/20260721100000_add_queue_executor_claim_owner.sql b/crates/db/migrations/20260721100000_add_queue_executor_claim_owner.sql new file mode 100644 index 00000000000..967496f22b3 --- /dev/null +++ b/crates/db/migrations/20260721100000_add_queue_executor_claim_owner.sql @@ -0,0 +1,5 @@ +-- Executor dispatch can legitimately take longer than terminal paste recovery. +-- A process-scoped owner lets reconciliation recover claims from a crashed +-- server without stealing a dispatch that is still in flight in this server. +ALTER TABLE session_queued_messages + ADD COLUMN executor_claim_owner TEXT; diff --git a/crates/db/src/models/session_queued_message.rs b/crates/db/src/models/session_queued_message.rs index 1f679ebeb81..f51f6d92abb 100644 --- a/crates/db/src/models/session_queued_message.rs +++ b/crates/db/src/models/session_queued_message.rs @@ -48,6 +48,9 @@ pub struct SessionQueuedMessage { pub state: QueuedMessageState, pub failure_reason: Option, pub claude_session_id: Option, + #[serde(skip)] + #[ts(skip)] + pub executor_claim_owner: Option, pub pasted_at: Option>, pub acked_at: Option>, pub created_at: DateTime, @@ -77,7 +80,7 @@ pub struct QueueReconciliation { impl SessionQueuedMessage { const SELECT_FIELDS: &'static str = r#" id, session_id, prompt, executor_config, source, state, - failure_reason, claude_session_id, pasted_at, acked_at, + failure_reason, claude_session_id, executor_claim_owner, pasted_at, acked_at, created_at, updated_at "#; @@ -165,6 +168,7 @@ impl SessionQueuedMessage { source = $3, failure_reason = NULL, claude_session_id = NULL, + executor_claim_owner = NULL, pasted_at = NULL, acked_at = NULL, updated_at = datetime('now', 'subsec') @@ -201,29 +205,52 @@ impl SessionQueuedMessage { )) } - /// Claim a queued row for either paste or executor delivery. `pasting` is - /// the crash-recoverable in-flight state for both; a NULL sid distinguishes - /// an executor claim from a terminal paste claim. - pub async fn claim( + async fn claim( pool: &SqlitePool, id: Uuid, claude_session_id: Option<&str>, + executor_claim_owner: Option<&str>, ) -> Result, sqlx::Error> { - sqlx::query!( + let result = sqlx::query!( r#"UPDATE session_queued_messages SET state = 'pasting', claude_session_id = $1, - pasted_at = datetime('now', 'subsec'), + executor_claim_owner = $2, + pasted_at = CASE WHEN $2 IS NULL + THEN datetime('now', 'subsec') + ELSE NULL END, failure_reason = NULL, updated_at = datetime('now', 'subsec') - WHERE id = $2 AND state = 'queued'"#, + WHERE id = $3 AND state = 'queued'"#, claude_session_id, + executor_claim_owner, id ) .execute(pool) .await?; - let row = Self::find_by_id(pool, id).await?; - Ok(row.filter(|row| row.state == QueuedMessageState::Pasting)) + if result.rows_affected() == 0 { + return Ok(None); + } + Self::find_by_id(pool, id).await + } + + /// Claim a queued row for terminal paste delivery. + pub async fn claim_for_paste( + pool: &SqlitePool, + id: Uuid, + claude_session_id: Option<&str>, + ) -> Result, sqlx::Error> { + Self::claim(pool, id, claude_session_id, None).await + } + + /// Claim a queued row for executor delivery. The process-scoped owner is + /// retained until consumption so reconciliation cannot steal a live claim. + pub async fn claim_for_executor( + pool: &SqlitePool, + id: Uuid, + owner: &str, + ) -> Result, sqlx::Error> { + Self::claim(pool, id, None, Some(owner)).await } pub async fn mark_pasted(pool: &SqlitePool, id: Uuid) -> Result { @@ -231,7 +258,8 @@ impl SessionQueuedMessage { r#"UPDATE session_queued_messages SET state = 'pasted', updated_at = datetime('now', 'subsec') - WHERE id = $1 AND state = 'pasting'"#, + WHERE id = $1 AND state = 'pasting' + AND executor_claim_owner IS NULL"#, id ) .execute(pool) @@ -248,6 +276,7 @@ impl SessionQueuedMessage { r#"UPDATE session_queued_messages SET state = 'queued', failure_reason = $1, + executor_claim_owner = NULL, claude_session_id = CASE WHEN state = 'pasted' THEN claude_session_id ELSE NULL @@ -292,13 +321,19 @@ impl SessionQueuedMessage { Ok(result.rows_affected() > 0) } - pub async fn mark_consumed(pool: &SqlitePool, id: Uuid) -> Result { + pub async fn mark_consumed( + pool: &SqlitePool, + id: Uuid, + owner: &str, + ) -> Result { let result = sqlx::query!( r#"UPDATE session_queued_messages SET state = 'consumed', updated_at = datetime('now', 'subsec') - WHERE id = $1 AND state = 'pasting'"#, - id + WHERE id = $1 AND state = 'pasting' + AND executor_claim_owner = $2"#, + id, + owner ) .execute(pool) .await?; @@ -339,6 +374,7 @@ impl SessionQueuedMessage { now: DateTime, pasting_grace: Duration, paste_ack_timeout: Duration, + active_executor_claim_owner: Option<&str>, ) -> Result { let imported = sqlx::query!( r#"UPDATE session_queued_messages SET @@ -369,11 +405,18 @@ impl SessionQueuedMessage { state = 'queued', failure_reason = 'delivery interrupted; queued for retry', claude_session_id = NULL, + executor_claim_owner = NULL, pasted_at = NULL, updated_at = datetime('now', 'subsec') WHERE state = 'pasting' - AND julianday(updated_at) <= julianday($1)"#, - pasting_cutoff + AND ( + (executor_claim_owner IS NULL + AND julianday(updated_at) <= julianday($1)) + OR (executor_claim_owner IS NOT NULL + AND ($2 IS NULL OR executor_claim_owner != $2)) + )"#, + pasting_cutoff, + active_executor_claim_owner ) .execute(pool) .await? @@ -496,7 +539,7 @@ mod tests { && row.source == QueuedMessageSource::Recovery )); - SessionQueuedMessage::claim(&pool, first.id, Some("sid")) + SessionQueuedMessage::claim_for_paste(&pool, first.id, Some("sid")) .await .unwrap() .unwrap(); @@ -534,7 +577,7 @@ mod tests { StoreQueuedMessageResult::Stored(row) => row, StoreQueuedMessageResult::Conflict(_) => unreachable!(), }; - SessionQueuedMessage::claim(&pool, row.id, Some("sid")) + SessionQueuedMessage::claim_for_paste(&pool, row.id, Some("sid")) .await .unwrap(); let old = Utc::now() - Duration::seconds(10); @@ -549,6 +592,7 @@ mod tests { Utc::now(), Duration::seconds(5), Duration::seconds(30), + None, ) .await .unwrap(); @@ -562,7 +606,7 @@ mod tests { QueuedMessageState::Queued ); - SessionQueuedMessage::claim(&pool, row.id, Some("sid")) + SessionQueuedMessage::claim_for_paste(&pool, row.id, Some("sid")) .await .unwrap(); SessionQueuedMessage::mark_pasted(&pool, row.id) @@ -580,6 +624,7 @@ mod tests { Utc::now(), Duration::seconds(5), Duration::minutes(15), + None, ) .await .unwrap(); @@ -605,6 +650,7 @@ mod tests { Utc::now(), Duration::seconds(5), Duration::minutes(15), + None, ) .await .unwrap(); @@ -622,4 +668,64 @@ mod tests { Some(PASTED_REQUEUE_FAILURE_REASON) ); } + + #[tokio::test] + async fn reconciliation_preserves_live_executor_claim_and_recovers_crashed_owner() { + let (pool, session_id) = session_fixture().await; + let row = match SessionQueuedMessage::store( + &pool, + session_id, + "executor claim", + None, + QueuedMessageSource::Ui, + false, + ) + .await + .unwrap() + { + StoreQueuedMessageResult::Stored(row) => row, + StoreQueuedMessageResult::Conflict(_) => unreachable!(), + }; + SessionQueuedMessage::claim_for_executor(&pool, row.id, "runtime-a") + .await + .unwrap() + .unwrap(); + let old = Utc::now() - Duration::seconds(10); + sqlx::query("UPDATE session_queued_messages SET updated_at = ? WHERE id = ?") + .bind(old) + .bind(row.id) + .execute(&pool) + .await + .unwrap(); + + let live = SessionQueuedMessage::reconcile( + &pool, + Utc::now(), + Duration::seconds(5), + Duration::minutes(15), + Some("runtime-a"), + ) + .await + .unwrap(); + assert_eq!(live.requeued_pasting, 0); + + let crashed = SessionQueuedMessage::reconcile( + &pool, + Utc::now(), + Duration::seconds(5), + Duration::minutes(15), + Some("runtime-b"), + ) + .await + .unwrap(); + assert_eq!(crashed.requeued_pasting, 1); + assert_eq!( + SessionQueuedMessage::find_by_id(&pool, row.id) + .await + .unwrap() + .unwrap() + .state, + QueuedMessageState::Queued + ); + } } diff --git a/crates/services/src/services/claude_transcript_ingest/tests.rs b/crates/services/src/services/claude_transcript_ingest/tests.rs index 39683fb12ad..2b393aa2ae9 100644 --- a/crates/services/src/services/claude_transcript_ingest/tests.rs +++ b/crates/services/src/services/claude_transcript_ingest/tests.rs @@ -235,7 +235,7 @@ async fn pasted_slot( StoreQueuedMessageResult::Stored(row) => row, StoreQueuedMessageResult::Conflict(_) => panic!("fixture slot must be empty"), }; - SessionQueuedMessage::claim(&db.pool, row.id, Some(sid)) + SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid)) .await .unwrap() .unwrap(); @@ -632,7 +632,7 @@ async fn late_paste_ack_imports_requeued_slot_and_blocks_duplicate_claim() { assert_eq!(imported.state, QueuedMessageState::Imported); assert!(imported.acked_at.is_some()); assert!( - SessionQueuedMessage::claim(&db.pool, slot.id, None) + SessionQueuedMessage::claim_for_executor(&db.pool, slot.id, "test-owner") .await .unwrap() .is_none() diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index b9995d08a21..29be46e43f6 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -134,6 +134,8 @@ pub enum CliCollabError { Serialize(#[from] serde_json::Error), #[error("session {0} has no workspace")] WorkspaceMissing(Uuid), + #[error("queued message {0} lost its executor dispatch claim")] + ExecutorClaimLost(Uuid), #[error("CLI transport failed for workspace {workspace_id}: {source}")] Transport { workspace_id: Uuid, @@ -150,6 +152,7 @@ pub struct CliCollabService { ingest: Option>, session_locks: Mutex>>>, resume_signaled_bindings: Mutex>, + executor_claim_owner: String, notify: Notify, routing_disabled: bool, shutdown: CancellationToken, @@ -172,6 +175,7 @@ impl CliCollabService { ingest, session_locks: Mutex::new(HashMap::new()), resume_signaled_bindings: Mutex::new(HashSet::new()), + executor_claim_owner: Uuid::new_v4().to_string(), notify: Notify::new(), routing_disabled: std::env::var_os("DISABLE_CLI_COLLAB_ROUTING").is_some(), shutdown, @@ -513,7 +517,7 @@ impl CliCollabService { claude_session_id: Option<&str>, ) -> Result { let Some(claimed) = - SessionQueuedMessage::claim(&self.db.pool, row.id, claude_session_id).await? + SessionQueuedMessage::claim_for_paste(&self.db.pool, row.id, claude_session_id).await? else { return Ok(DispatchOutcome::Conflict { status: self.status(session.id).await?, @@ -570,7 +574,13 @@ impl CliCollabService { }; let claimed = if let Some(row) = existing { - match SessionQueuedMessage::claim(&self.db.pool, row.id, None).await? { + match SessionQueuedMessage::claim_for_executor( + &self.db.pool, + row.id, + &self.executor_claim_owner, + ) + .await? + { Some(row) => Some(row), None => { let _ = WorkspaceSpawnReservation::release( @@ -605,7 +615,15 @@ impl CliCollabService { match dispatched { Ok(execution_process) => { if let Some(row) = claimed { - SessionQueuedMessage::mark_consumed(&self.db.pool, row.id).await?; + if !SessionQueuedMessage::mark_consumed( + &self.db.pool, + row.id, + &self.executor_claim_owner, + ) + .await? + { + return Err(CliCollabError::ExecutorClaimLost(row.id)); + } } Ok(DispatchOutcome::Started { execution_process }) } @@ -941,6 +959,7 @@ impl CliCollabService { Utc::now(), PASTING_STARTUP_GRACE, PASTE_ACK_HARD_CAP, + Some(&self.executor_claim_owner), ) .await { @@ -1164,7 +1183,10 @@ pub const COLLAB_RUN_REASON: ExecutionProcessRunReason = ExecutionProcessRunReas #[cfg(test)] mod tests { - use std::sync::Mutex as StdMutex; + use std::sync::{ + Mutex as StdMutex, + atomic::{AtomicUsize, Ordering}, + }; use db::models::{ claude_session_link::ClaudeSessionLink, @@ -1348,6 +1370,28 @@ mod tests { state: Arc>, } + struct SlowDispatcher { + db: DBService, + calls: Arc, + started: Arc, + release: Arc, + } + + #[async_trait] + impl CliExecutorDispatcher for SlowDispatcher { + async fn dispatch( + &self, + session: &Session, + prompt: &str, + _executor_config: &ExecutorConfig, + ) -> AnyhowResult { + self.calls.fetch_add(1, Ordering::SeqCst); + self.started.notify_one(); + self.release.notified().await; + Ok(create_running_executor(&self.db, session.id, prompt).await) + } + } + #[async_trait] impl CliExecutorDispatcher for FailingDispatcher { async fn dispatch( @@ -1423,6 +1467,7 @@ mod tests { ingest: None, session_locks: Mutex::new(HashMap::new()), resume_signaled_bindings: Mutex::new(HashSet::new()), + executor_claim_owner: Uuid::new_v4().to_string(), notify: Notify::new(), routing_disabled: false, shutdown: CancellationToken::new(), @@ -2006,6 +2051,87 @@ mod tests { assert_eq!(queued.prompt, "preserve failed dispatch"); } + #[tokio::test] + async fn slow_executor_dispatch_is_not_reconciled_or_run_twice() { + let (db, _workspace, session) = fixture().await; + let row = store_message(&db, session.id, "run exactly once", QueuedMessageSource::Ui).await; + let calls = Arc::new(AtomicUsize::new(0)); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let (service, _) = service_with_components( + db.clone(), + report(false, Some(false), SidEvidence::Unknown), + Arc::new(FakeTransport), + Arc::new(SlowDispatcher { + db: db.clone(), + calls: calls.clone(), + started: started.clone(), + release: release.clone(), + }), + ); + let service = Arc::new(service); + let draining = { + let service = service.clone(); + tokio::spawn(async move { service.drain_session(session.id).await }) + }; + + started.notified().await; + let older_than_paste_grace = + Utc::now() - PASTING_STARTUP_GRACE - ChronoDuration::seconds(1); + sqlx::query("UPDATE session_queued_messages SET updated_at = ? WHERE id = ?") + .bind(older_than_paste_grace) + .bind(row.id) + .execute(&db.pool) + .await + .unwrap(); + let reconciled = SessionQueuedMessage::reconcile( + &db.pool, + Utc::now(), + PASTING_STARTUP_GRACE, + PASTE_ACK_HARD_CAP, + Some(&service.executor_claim_owner), + ) + .await + .unwrap(); + assert_eq!(reconciled.requeued_pasting, 0); + assert_eq!( + SessionQueuedMessage::find_by_id(&db.pool, row.id) + .await + .unwrap() + .unwrap() + .state, + QueuedMessageState::Pasting + ); + + release.notify_one(); + assert!(draining.await.unwrap().unwrap()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!( + SessionQueuedMessage::find_by_id(&db.pool, row.id) + .await + .unwrap() + .unwrap() + .state, + QueuedMessageState::Consumed + ); + + let execution = ExecutionProcess::find_running(&db.pool) + .await + .unwrap() + .pop() + .unwrap(); + ExecutionProcess::update_completion( + &db.pool, + execution.id, + ExecutionProcessStatus::Completed, + Some(0), + ) + .await + .unwrap(); + assert!(!service.on_executor_finished(session.id).await.unwrap()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + #[tokio::test] async fn replace_contract_preserves_conflict_details_and_rejects_in_flight_replace() { let (db, _workspace, session) = fixture().await; @@ -2062,7 +2188,7 @@ mod tests { assert_eq!(replaced_message.data.message, "new UI prompt"); assert_eq!(replaced_message.source, QueuedMessageSource::Ui); - SessionQueuedMessage::claim(&db.pool, first_id, Some("sid")) + SessionQueuedMessage::claim_for_paste(&db.pool, first_id, Some("sid")) .await .unwrap() .unwrap(); @@ -2102,7 +2228,7 @@ mod tests { QueuedMessageSource::Ui, ) .await; - SessionQueuedMessage::claim(&db.pool, row.id, Some(sid)) + SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid)) .await .unwrap() .unwrap(); @@ -2168,7 +2294,7 @@ mod tests { QueuedMessageSource::Recovery, ) .await; - SessionQueuedMessage::claim(&db.pool, row.id, Some(sid)) + SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid)) .await .unwrap() .unwrap(); @@ -2216,7 +2342,7 @@ mod tests { QueuedMessageSource::Ui, ) .await; - SessionQueuedMessage::claim(&db.pool, row.id, Some(sid)) + SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid)) .await .unwrap() .unwrap(); @@ -2284,7 +2410,7 @@ mod tests { QueuedMessageSource::Ui, ) .await; - SessionQueuedMessage::claim(&db.pool, row.id, Some(sid)) + SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid)) .await .unwrap() .unwrap(); @@ -2361,7 +2487,7 @@ mod tests { QueuedMessageSource::Recovery, ) .await; - SessionQueuedMessage::claim(&db.pool, interrupted.id, Some("interrupted-sid")) + SessionQueuedMessage::claim_for_paste(&db.pool, interrupted.id, Some("interrupted-sid")) .await .unwrap() .unwrap(); @@ -2381,7 +2507,7 @@ mod tests { QueuedMessageSource::Ui, ) .await; - SessionQueuedMessage::claim(&db.pool, acknowledged.id, Some("acknowledged-sid")) + SessionQueuedMessage::claim_for_paste(&db.pool, acknowledged.id, Some("acknowledged-sid")) .await .unwrap() .unwrap(); diff --git a/crates/services/src/services/queued_message.rs b/crates/services/src/services/queued_message.rs index 0f41b4609e9..bbf29ffd7ba 100644 --- a/crates/services/src/services/queued_message.rs +++ b/crates/services/src/services/queued_message.rs @@ -26,6 +26,8 @@ pub enum QueuedMessageError { #[source] source: serde_json::Error, }, + #[error("queued message {0} lost its executor claim before consumption")] + ExecutorClaimLost(Uuid), } /// Durable frontend-facing representation of the session's collaboration @@ -193,13 +195,21 @@ impl QueuedMessageService { return Ok(None); }; if row.state != QueuedMessageState::Queued - || SessionQueuedMessage::claim(&self.db.pool, row.id, None) - .await? - .is_none() + || SessionQueuedMessage::claim_for_executor( + &self.db.pool, + row.id, + "queued-message-service", + ) + .await? + .is_none() { return Ok(None); } - SessionQueuedMessage::mark_consumed(&self.db.pool, row.id).await?; + if !SessionQueuedMessage::mark_consumed(&self.db.pool, row.id, "queued-message-service") + .await? + { + return Err(QueuedMessageError::ExecutorClaimLost(row.id)); + } Ok(Some(row.try_into()?)) } From 6125487ec47e911548781cb0107db16d8b20e35b Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 23:48:03 +0000 Subject: [PATCH 22/36] fix(collab): serialize queue mutations --- ...76cb3bc30e55e3de4fcd48f26b4946890517c.json | 12 -- ...0101a752aa2b6efe8fe33f080ef33b9a016de.json | 12 -- ...602a999e480568a9840c520b76394638a8df0.json | 12 -- ...49ecb596cecb481446636d8549d526d44fc9d.json | 12 ++ .../db/src/models/session_queued_message.rs | 200 ++++++++++++------ crates/server/src/routes/sessions/queue.rs | 24 ++- crates/services/src/services/cli_collab.rs | 44 ++-- .../services/src/services/queued_message.rs | 52 +---- 8 files changed, 190 insertions(+), 178 deletions(-) delete mode 100644 crates/db/.sqlx/query-258c9f617b90b017353de11caf176cb3bc30e55e3de4fcd48f26b4946890517c.json delete mode 100644 crates/db/.sqlx/query-7dc08c213e156cb7d1bd2396d120101a752aa2b6efe8fe33f080ef33b9a016de.json delete mode 100644 crates/db/.sqlx/query-7f72352f40bc3e40e74c4cd4f29602a999e480568a9840c520b76394638a8df0.json create mode 100644 crates/db/.sqlx/query-94d47a154ea639f550757c3a01c49ecb596cecb481446636d8549d526d44fc9d.json diff --git a/crates/db/.sqlx/query-258c9f617b90b017353de11caf176cb3bc30e55e3de4fcd48f26b4946890517c.json b/crates/db/.sqlx/query-258c9f617b90b017353de11caf176cb3bc30e55e3de4fcd48f26b4946890517c.json deleted file mode 100644 index 9aef8ac1b6b..00000000000 --- a/crates/db/.sqlx/query-258c9f617b90b017353de11caf176cb3bc30e55e3de4fcd48f26b4946890517c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO session_queued_messages\n (id, session_id, prompt, executor_config, source, state)\n VALUES ($1, $2, $3, $4, $5, 'queued')", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "258c9f617b90b017353de11caf176cb3bc30e55e3de4fcd48f26b4946890517c" -} diff --git a/crates/db/.sqlx/query-7dc08c213e156cb7d1bd2396d120101a752aa2b6efe8fe33f080ef33b9a016de.json b/crates/db/.sqlx/query-7dc08c213e156cb7d1bd2396d120101a752aa2b6efe8fe33f080ef33b9a016de.json deleted file mode 100644 index 6fb7badb1e2..00000000000 --- a/crates/db/.sqlx/query-7dc08c213e156cb7d1bd2396d120101a752aa2b6efe8fe33f080ef33b9a016de.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'cancelled',\n updated_at = datetime('now', 'subsec')\n WHERE id = $1 AND state = 'queued'", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "7dc08c213e156cb7d1bd2396d120101a752aa2b6efe8fe33f080ef33b9a016de" -} diff --git a/crates/db/.sqlx/query-7f72352f40bc3e40e74c4cd4f29602a999e480568a9840c520b76394638a8df0.json b/crates/db/.sqlx/query-7f72352f40bc3e40e74c4cd4f29602a999e480568a9840c520b76394638a8df0.json deleted file mode 100644 index f38a6c6b773..00000000000 --- a/crates/db/.sqlx/query-7f72352f40bc3e40e74c4cd4f29602a999e480568a9840c520b76394638a8df0.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n prompt = $1,\n executor_config = $2,\n source = $3,\n failure_reason = NULL,\n claude_session_id = NULL,\n executor_claim_owner = NULL,\n pasted_at = NULL,\n acked_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $4 AND state = 'queued'", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "7f72352f40bc3e40e74c4cd4f29602a999e480568a9840c520b76394638a8df0" -} diff --git a/crates/db/.sqlx/query-94d47a154ea639f550757c3a01c49ecb596cecb481446636d8549d526d44fc9d.json b/crates/db/.sqlx/query-94d47a154ea639f550757c3a01c49ecb596cecb481446636d8549d526d44fc9d.json new file mode 100644 index 00000000000..ac1359a39b8 --- /dev/null +++ b/crates/db/.sqlx/query-94d47a154ea639f550757c3a01c49ecb596cecb481446636d8549d526d44fc9d.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO session_queued_messages\n (id, session_id, prompt, executor_config, source, state)\n VALUES ($1, $2, $3, $4, $5, 'queued')", + "describe": { + "columns": [], + "parameters": { + "Right": 5 + }, + "nullable": [] + }, + "hash": "94d47a154ea639f550757c3a01c49ecb596cecb481446636d8549d526d44fc9d" +} diff --git a/crates/db/src/models/session_queued_message.rs b/crates/db/src/models/session_queued_message.rs index f51f6d92abb..ca80f0f19b7 100644 --- a/crates/db/src/models/session_queued_message.rs +++ b/crates/db/src/models/session_queued_message.rs @@ -144,24 +144,8 @@ impl SessionQueuedMessage { source: QueuedMessageSource, replace: bool, ) -> Result { - let mut tx = pool.begin().await?; - let sql = format!( - "SELECT {} FROM session_queued_messages \ - WHERE session_id = ? AND state IN ('queued', 'pasting', 'pasted') \ - ORDER BY created_at DESC LIMIT 1", - Self::SELECT_FIELDS - ); - let existing = sqlx::query_as::<_, Self>(&sql) - .bind(session_id) - .fetch_optional(&mut *tx) - .await?; - - let id = if let Some(existing) = existing { - if !replace || existing.state != QueuedMessageState::Queued { - tx.rollback().await?; - return Ok(StoreQueuedMessageResult::Conflict(existing)); - } - sqlx::query!( + if replace { + let sql = format!( r#"UPDATE session_queued_messages SET prompt = $1, executor_config = $2, @@ -172,37 +156,43 @@ impl SessionQueuedMessage { pasted_at = NULL, acked_at = NULL, updated_at = datetime('now', 'subsec') - WHERE id = $4 AND state = 'queued'"#, - prompt, - executor_config, - source, - existing.id - ) - .execute(&mut *tx) - .await?; - existing.id - } else { - let id = Uuid::new_v4(); - sqlx::query!( - r#"INSERT INTO session_queued_messages - (id, session_id, prompt, executor_config, source, state) - VALUES ($1, $2, $3, $4, $5, 'queued')"#, - id, - session_id, - prompt, - executor_config, - source - ) - .execute(&mut *tx) - .await?; - id - }; - tx.commit().await?; - Ok(StoreQueuedMessageResult::Stored( - Self::find_by_id(pool, id) + WHERE session_id = $4 AND state = 'queued' + RETURNING {}"#, + Self::SELECT_FIELDS + ); + if let Some(row) = sqlx::query_as::<_, Self>(&sql) + .bind(prompt) + .bind(executor_config) + .bind(source) + .bind(session_id) + .fetch_optional(pool) .await? - .expect("stored queue row exists"), - )) + { + return Ok(StoreQueuedMessageResult::Stored(row)); + } + } + + if let Some(existing) = Self::find_active(pool, session_id).await? { + return Ok(StoreQueuedMessageResult::Conflict(existing)); + } + + let id = Uuid::new_v4(); + sqlx::query!( + r#"INSERT INTO session_queued_messages + (id, session_id, prompt, executor_config, source, state) + VALUES ($1, $2, $3, $4, $5, 'queued')"#, + id, + session_id, + prompt, + executor_config, + source + ) + .execute(pool) + .await?; + Self::find_by_id(pool, id) + .await? + .map(StoreQueuedMessageResult::Stored) + .ok_or(sqlx::Error::RowNotFound) } async fn claim( @@ -344,26 +334,25 @@ impl SessionQueuedMessage { pool: &SqlitePool, session_id: Uuid, ) -> Result { - let Some(active) = Self::find_active(pool, session_id).await? else { - return Ok(CancelQueuedMessageResult::Empty); - }; - if active.state != QueuedMessageState::Queued { - return Ok(CancelQueuedMessageResult::Conflict(active)); - } - sqlx::query!( + let sql = format!( r#"UPDATE session_queued_messages SET state = 'cancelled', updated_at = datetime('now', 'subsec') - WHERE id = $1 AND state = 'queued'"#, - active.id - ) - .execute(pool) - .await?; - Ok(CancelQueuedMessageResult::Cancelled( - Self::find_by_id(pool, active.id) - .await? - .expect("cancelled queue row exists"), - )) + WHERE session_id = $1 AND state = 'queued' + RETURNING {}"#, + Self::SELECT_FIELDS + ); + if let Some(cancelled) = sqlx::query_as::<_, Self>(&sql) + .bind(session_id) + .fetch_optional(pool) + .await? + { + return Ok(CancelQueuedMessageResult::Cancelled(cancelled)); + } + Ok(match Self::find_active(pool, session_id).await? { + Some(active) => CancelQueuedMessageResult::Conflict(active), + None => CancelQueuedMessageResult::Empty, + }) } /// Reconcile delivery rows after startup or a periodic drain. Ack evidence @@ -560,6 +549,87 @@ mod tests { )); } + #[tokio::test] + async fn replace_losing_a_queued_slot_reports_conflict_instead_of_success() { + let (pool, session_id) = session_fixture().await; + SessionQueuedMessage::store( + &pool, + session_id, + "original", + None, + QueuedMessageSource::Ui, + false, + ) + .await + .unwrap(); + sqlx::query( + r#"CREATE TRIGGER simulate_replace_claim_race + BEFORE UPDATE OF prompt ON session_queued_messages + WHEN OLD.state = 'queued' + BEGIN + UPDATE session_queued_messages SET state = 'pasting' + WHERE id = OLD.id; + SELECT RAISE(IGNORE); + END"#, + ) + .execute(&pool) + .await + .unwrap(); + + let result = SessionQueuedMessage::store( + &pool, + session_id, + "replacement", + None, + QueuedMessageSource::Recovery, + true, + ) + .await + .unwrap(); + assert!(matches!( + result, + StoreQueuedMessageResult::Conflict(ref row) + if row.prompt == "original" && row.state == QueuedMessageState::Pasting + )); + } + + #[tokio::test] + async fn cancel_losing_a_claim_race_does_not_report_false_success() { + let (pool, session_id) = session_fixture().await; + SessionQueuedMessage::store( + &pool, + session_id, + "claim wins", + None, + QueuedMessageSource::Ui, + false, + ) + .await + .unwrap(); + sqlx::query( + r#"CREATE TRIGGER simulate_cancel_claim_race + BEFORE UPDATE OF state ON session_queued_messages + WHEN OLD.state = 'queued' AND NEW.state = 'cancelled' + BEGIN + UPDATE session_queued_messages SET state = 'pasting' + WHERE id = OLD.id; + SELECT RAISE(IGNORE); + END"#, + ) + .execute(&pool) + .await + .unwrap(); + + let result = SessionQueuedMessage::cancel_queued(&pool, session_id) + .await + .unwrap(); + assert!(matches!( + result, + CancelQueuedMessageResult::Conflict(ref row) + if row.state == QueuedMessageState::Pasting + )); + } + #[tokio::test] async fn delivery_reconciliation_recovers_pasting_but_only_hard_caps_pasted_rows() { let (pool, session_id) = session_fixture().await; diff --git a/crates/server/src/routes/sessions/queue.rs b/crates/server/src/routes/sessions/queue.rs index b039750398c..e49fd811e37 100644 --- a/crates/server/src/routes/sessions/queue.rs +++ b/crates/server/src/routes/sessions/queue.rs @@ -49,9 +49,19 @@ async fn queue_message( executor_config: payload.executor_config, }; let result = deployment - .queued_message_service() - .queue_message(session.id, data, QueuedMessageSource::Ui, payload.replace) - .await?; + .cli_collab() + .queue_message( + &session, + data.message, + data.executor_config, + QueuedMessageSource::Ui, + payload.replace, + ) + .await + .map_err(|error| { + tracing::warn!(?error, session_id = %session.id, "queue mutation failed closed"); + ApiError::Conflict("Queue state changed; reload and try again".to_string()) + })?; if matches!(result, QueueMutation::Stored(_)) { deployment @@ -72,9 +82,13 @@ async fn cancel_queued_message( State(deployment): State, ) -> Result { let result = deployment - .queued_message_service() + .cli_collab() .cancel_queued(session.id) - .await?; + .await + .map_err(|error| { + tracing::warn!(?error, session_id = %session.id, "queue cancellation failed closed"); + ApiError::Conflict("Queue state changed; reload and try again".to_string()) + })?; if matches!(result, QueueMutation::Stored(_)) { deployment .track_if_analytics_allowed( diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index 29be46e43f6..e0ad2b87253 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -33,7 +33,7 @@ use uuid::Uuid; use super::{ claude_transcript_ingest::ClaudeTranscriptIngest, - queued_message::{QueueStatus, QueuedMessage, QueuedMessageError}, + queued_message::{QueueMutation, QueueStatus, QueuedMessage, QueuedMessageError}, }; const DRAIN_INTERVAL: Duration = Duration::from_secs(1); @@ -381,32 +381,34 @@ impl CliCollabService { Ok(outcome) } - pub async fn queue_only( + pub async fn queue_message( &self, session: &Session, prompt: String, executor_config: ExecutorConfig, source: QueuedMessageSource, replace: bool, - ) -> Result { + ) -> Result { let lock = self.session_lock(session.id).await; let _guard = lock.lock().await; let result = self .store_slot_result(session.id, &prompt, &executor_config, source, replace) .await?; let outcome = match result { - StoreQueuedMessageResult::Stored(row) => DispatchOutcome::Queued { - status: Self::status_from_row(row)?, - }, - StoreQueuedMessageResult::Conflict(row) => DispatchOutcome::Conflict { - status: Self::status_from_row(row)?, - }, + StoreQueuedMessageResult::Stored(row) => { + QueueMutation::Stored(Self::status_from_row(row)?) + } + StoreQueuedMessageResult::Conflict(row) => { + QueueMutation::Conflict(Self::status_from_row(row)?) + } }; - self.notify.notify_one(); + if matches!(outcome, QueueMutation::Stored(_)) { + self.notify.notify_one(); + } Ok(outcome) } - pub async fn cancel_queued(&self, session_id: Uuid) -> Result { + pub async fn cancel_queued(&self, session_id: Uuid) -> Result { let lock = self.session_lock(session_id).await; let _guard = lock.lock().await; match db::models::session_queued_message::SessionQueuedMessage::cancel_queued( @@ -417,10 +419,10 @@ impl CliCollabService { { db::models::session_queued_message::CancelQueuedMessageResult::Empty | db::models::session_queued_message::CancelQueuedMessageResult::Cancelled(_) => { - Ok(QueueStatus::Empty) + Ok(QueueMutation::Stored(QueueStatus::Empty)) } db::models::session_queued_message::CancelQueuedMessageResult::Conflict(row) => { - Self::status_from_row(row) + Ok(QueueMutation::Conflict(Self::status_from_row(row)?)) } } } @@ -2138,7 +2140,7 @@ mod tests { let (service, _) = service(db.clone(), report(false, Some(false), SidEvidence::Unknown)); let first = service - .queue_only( + .queue_message( &session, "queued from recovery".to_string(), executor_config(), @@ -2147,13 +2149,13 @@ mod tests { ) .await .unwrap(); - let DispatchOutcome::Queued { status } = first else { + let QueueMutation::Stored(status) = first else { panic!("empty slot must accept the first prompt"); }; let first_id = status.message().unwrap().id; let conflict = service - .queue_only( + .queue_message( &session, "new UI prompt".to_string(), executor_config(), @@ -2162,7 +2164,7 @@ mod tests { ) .await .unwrap(); - let DispatchOutcome::Conflict { status } = conflict else { + let QueueMutation::Conflict(status) = conflict else { panic!("queued replacement requires explicit confirmation"); }; let conflict_message = status.message().unwrap(); @@ -2171,7 +2173,7 @@ mod tests { assert_eq!(conflict_message.source, QueuedMessageSource::Recovery); let replaced = service - .queue_only( + .queue_message( &session, "new UI prompt".to_string(), executor_config(), @@ -2180,7 +2182,7 @@ mod tests { ) .await .unwrap(); - let DispatchOutcome::Queued { status } = replaced else { + let QueueMutation::Stored(status) = replaced else { panic!("explicit queued replacement must succeed"); }; let replaced_message = status.message().unwrap(); @@ -2198,7 +2200,7 @@ mod tests { .unwrap() ); let in_flight = service - .queue_only( + .queue_message( &session, "replace pasted".to_string(), executor_config(), @@ -2207,7 +2209,7 @@ mod tests { ) .await .unwrap(); - let DispatchOutcome::Conflict { status } = in_flight else { + let QueueMutation::Conflict(status) = in_flight else { panic!("pasted delivery must never be replaced"); }; let in_flight_message = status.message().unwrap(); diff --git a/crates/services/src/services/queued_message.rs b/crates/services/src/services/queued_message.rs index bbf29ffd7ba..57ce3a78a3a 100644 --- a/crates/services/src/services/queued_message.rs +++ b/crates/services/src/services/queued_message.rs @@ -3,10 +3,7 @@ use db::{ DBService, models::{ scratch::DraftFollowUpData, - session_queued_message::{ - CancelQueuedMessageResult, QueuedMessageSource, QueuedMessageState, - SessionQueuedMessage, StoreQueuedMessageResult, - }, + session_queued_message::{QueuedMessageSource, QueuedMessageState, SessionQueuedMessage}, }, }; use serde::{Deserialize, Serialize}; @@ -127,53 +124,6 @@ impl QueuedMessageService { Self { db } } - pub async fn queue_message( - &self, - session_id: Uuid, - data: DraftFollowUpData, - source: QueuedMessageSource, - replace: bool, - ) -> Result { - let executor_config = serde_json::to_string(&data.executor_config).map_err(|source| { - QueuedMessageError::InvalidExecutorConfig { - id: Uuid::nil(), - source, - } - })?; - match SessionQueuedMessage::store( - &self.db.pool, - session_id, - &data.message, - Some(&executor_config), - source, - replace, - ) - .await? - { - StoreQueuedMessageResult::Stored(row) => Ok(QueueMutation::Stored( - QueueStatus::from_message(row.try_into()?), - )), - StoreQueuedMessageResult::Conflict(row) => Ok(QueueMutation::Conflict( - QueueStatus::from_message(row.try_into()?), - )), - } - } - - pub async fn cancel_queued( - &self, - session_id: Uuid, - ) -> Result { - match SessionQueuedMessage::cancel_queued(&self.db.pool, session_id).await? { - CancelQueuedMessageResult::Empty => Ok(QueueMutation::Stored(QueueStatus::Empty)), - CancelQueuedMessageResult::Cancelled(_) => { - Ok(QueueMutation::Stored(QueueStatus::Empty)) - } - CancelQueuedMessageResult::Conflict(row) => Ok(QueueMutation::Conflict( - QueueStatus::from_message(row.try_into()?), - )), - } - } - pub async fn get_queued( &self, session_id: Uuid, From 5dcb0a886f2a569b5458a5d025bd8b85042fc4a8 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Tue, 21 Jul 2026 23:58:37 +0000 Subject: [PATCH 23/36] fix(retry): gate reset and preserve transcript truncation --- ...1c1df08d4e41c5a1130ab8b0ed63d9bebcff4.json | 12 + ...49ecb596cecb481446636d8549d526d44fc9d.json | 12 - ...60721101000_add_queue_dispatch_context.sql | 4 + .../db/src/models/session_queued_message.rs | 36 ++- crates/local-deployment/src/container.rs | 98 ++++-- crates/server/src/routes/sessions/mod.rs | 63 ++-- crates/services/src/services/cli_collab.rs | 284 ++++++++++++++++-- .../model/hooks/useMessageEditRetry.ts | 36 ++- .../ui/SessionChatBoxContainer.tsx | 23 +- .../RetryEditorInline.tsx | 29 +- .../dialogs/tasks/ResolveConflictsDialog.tsx | 48 ++- .../src/shared/hooks/useRetryProcess.ts | 34 ++- .../dispatchWithConflictResolution.test.ts | 59 ++++ .../lib/dispatchWithConflictResolution.ts | 33 ++ 14 files changed, 655 insertions(+), 116 deletions(-) create mode 100644 crates/db/.sqlx/query-633f7d5944fc57330b8c47af7e01c1df08d4e41c5a1130ab8b0ed63d9bebcff4.json delete mode 100644 crates/db/.sqlx/query-94d47a154ea639f550757c3a01c49ecb596cecb481446636d8549d526d44fc9d.json create mode 100644 crates/db/migrations/20260721101000_add_queue_dispatch_context.sql create mode 100644 packages/web-core/src/shared/lib/dispatchWithConflictResolution.test.ts create mode 100644 packages/web-core/src/shared/lib/dispatchWithConflictResolution.ts diff --git a/crates/db/.sqlx/query-633f7d5944fc57330b8c47af7e01c1df08d4e41c5a1130ab8b0ed63d9bebcff4.json b/crates/db/.sqlx/query-633f7d5944fc57330b8c47af7e01c1df08d4e41c5a1130ab8b0ed63d9bebcff4.json new file mode 100644 index 00000000000..2f1ae60b8ff --- /dev/null +++ b/crates/db/.sqlx/query-633f7d5944fc57330b8c47af7e01c1df08d4e41c5a1130ab8b0ed63d9bebcff4.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "INSERT INTO session_queued_messages\n (id, session_id, prompt, executor_config, dispatch_context, source, state)\n VALUES ($1, $2, $3, $4, $5, $6, 'queued')", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "633f7d5944fc57330b8c47af7e01c1df08d4e41c5a1130ab8b0ed63d9bebcff4" +} diff --git a/crates/db/.sqlx/query-94d47a154ea639f550757c3a01c49ecb596cecb481446636d8549d526d44fc9d.json b/crates/db/.sqlx/query-94d47a154ea639f550757c3a01c49ecb596cecb481446636d8549d526d44fc9d.json deleted file mode 100644 index ac1359a39b8..00000000000 --- a/crates/db/.sqlx/query-94d47a154ea639f550757c3a01c49ecb596cecb481446636d8549d526d44fc9d.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "INSERT INTO session_queued_messages\n (id, session_id, prompt, executor_config, source, state)\n VALUES ($1, $2, $3, $4, $5, 'queued')", - "describe": { - "columns": [], - "parameters": { - "Right": 5 - }, - "nullable": [] - }, - "hash": "94d47a154ea639f550757c3a01c49ecb596cecb481446636d8549d526d44fc9d" -} diff --git a/crates/db/migrations/20260721101000_add_queue_dispatch_context.sql b/crates/db/migrations/20260721101000_add_queue_dispatch_context.sql new file mode 100644 index 00000000000..ba5b83f0c98 --- /dev/null +++ b/crates/db/migrations/20260721101000_add_queue_dispatch_context.sql @@ -0,0 +1,4 @@ +-- Retry metadata must survive durable queueing so the destructive reset and +-- transcript truncation happen only when the queued executor is accepted. +ALTER TABLE session_queued_messages + ADD COLUMN dispatch_context TEXT; diff --git a/crates/db/src/models/session_queued_message.rs b/crates/db/src/models/session_queued_message.rs index ca80f0f19b7..43ae1e87b1a 100644 --- a/crates/db/src/models/session_queued_message.rs +++ b/crates/db/src/models/session_queued_message.rs @@ -51,6 +51,9 @@ pub struct SessionQueuedMessage { #[serde(skip)] #[ts(skip)] pub executor_claim_owner: Option, + #[serde(skip)] + #[ts(skip)] + pub dispatch_context: Option, pub pasted_at: Option>, pub acked_at: Option>, pub created_at: DateTime, @@ -80,7 +83,8 @@ pub struct QueueReconciliation { impl SessionQueuedMessage { const SELECT_FIELDS: &'static str = r#" id, session_id, prompt, executor_config, source, state, - failure_reason, claude_session_id, executor_claim_owner, pasted_at, acked_at, + failure_reason, claude_session_id, executor_claim_owner, dispatch_context, + pasted_at, acked_at, created_at, updated_at "#; @@ -143,6 +147,27 @@ impl SessionQueuedMessage { executor_config: Option<&str>, source: QueuedMessageSource, replace: bool, + ) -> Result { + Self::store_with_context( + pool, + session_id, + prompt, + executor_config, + None, + source, + replace, + ) + .await + } + + pub async fn store_with_context( + pool: &SqlitePool, + session_id: Uuid, + prompt: &str, + executor_config: Option<&str>, + dispatch_context: Option<&str>, + source: QueuedMessageSource, + replace: bool, ) -> Result { if replace { let sql = format!( @@ -153,10 +178,11 @@ impl SessionQueuedMessage { failure_reason = NULL, claude_session_id = NULL, executor_claim_owner = NULL, + dispatch_context = $4, pasted_at = NULL, acked_at = NULL, updated_at = datetime('now', 'subsec') - WHERE session_id = $4 AND state = 'queued' + WHERE session_id = $5 AND state = 'queued' RETURNING {}"#, Self::SELECT_FIELDS ); @@ -164,6 +190,7 @@ impl SessionQueuedMessage { .bind(prompt) .bind(executor_config) .bind(source) + .bind(dispatch_context) .bind(session_id) .fetch_optional(pool) .await? @@ -179,12 +206,13 @@ impl SessionQueuedMessage { let id = Uuid::new_v4(); sqlx::query!( r#"INSERT INTO session_queued_messages - (id, session_id, prompt, executor_config, source, state) - VALUES ($1, $2, $3, $4, $5, 'queued')"#, + (id, session_id, prompt, executor_config, dispatch_context, source, state) + VALUES ($1, $2, $3, $4, $5, $6, 'queued')"#, id, session_id, prompt, executor_config, + dispatch_context, source ) .execute(pool) diff --git a/crates/local-deployment/src/container.rs b/crates/local-deployment/src/container.rs index b212e0aa6a1..476bc389c9e 100644 --- a/crates/local-deployment/src/container.rs +++ b/crates/local-deployment/src/container.rs @@ -12,7 +12,7 @@ use command_group::AsyncGroupChild; use db::{ DBService, models::{ - coding_agent_turn::CodingAgentTurn, + coding_agent_turn::{CodingAgentResumeInfo, CodingAgentTurn}, execution_process::{ ExecutionContext, ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus, }, @@ -42,7 +42,7 @@ use serde_json::json; use services::services::{ analytics::AnalyticsContext, approvals::{Approvals, executor_approvals::ExecutorApprovalBridge}, - cli_collab::{CliCollabService, CliExecutorDispatcher}, + cli_collab::{CliCollabService, CliExecutorDispatcher, RetryDispatchContext}, config::{Config, DEFAULT_COMMIT_REMINDER_PROMPT}, container::{ContainerError, ContainerRef, ContainerService}, diff_stream::{self, DiffStreamHandle}, @@ -63,6 +63,29 @@ use workspace_manager::{RepoWorkspaceInput, WorkspaceError, WorkspaceManager}; use crate::{command, copy}; +fn queued_action_type( + queued_data: &DraftFollowUpData, + latest_session_info: Option, + working_dir: Option, + reset_to_message_id: Option, +) -> ExecutorActionType { + if let Some(info) = latest_session_info { + ExecutorActionType::CodingAgentFollowUpRequest(CodingAgentFollowUpRequest { + prompt: queued_data.message.clone(), + session_id: info.session_id, + reset_to_message_id, + executor_config: queued_data.executor_config.clone(), + working_dir, + }) + } else { + ExecutorActionType::CodingAgentInitialRequest(CodingAgentInitialRequest { + prompt: queued_data.message.clone(), + executor_config: queued_data.executor_config.clone(), + working_dir, + }) + } +} + const WORKSPACE_TOUCH_DEBOUNCE: Duration = Duration::from_mins(2); #[derive(Clone)] @@ -1121,6 +1144,7 @@ impl LocalContainerService { workspace: &Workspace, session: &Session, queued_data: &DraftFollowUpData, + reset_to_message_id: Option, ) -> Result { let executor_profile_id = queued_data.executor_config.profile_id(); @@ -1160,21 +1184,12 @@ impl LocalContainerService { .filter(|dir| !dir.is_empty()) .cloned(); - let action_type = if let Some(info) = latest_session_info { - ExecutorActionType::CodingAgentFollowUpRequest(CodingAgentFollowUpRequest { - prompt: queued_data.message.clone(), - session_id: info.session_id, - reset_to_message_id: None, - executor_config: queued_data.executor_config.clone(), - working_dir: working_dir.clone(), - }) - } else { - ExecutorActionType::CodingAgentInitialRequest(CodingAgentInitialRequest { - prompt: queued_data.message.clone(), - executor_config: queued_data.executor_config.clone(), - working_dir, - }) - }; + let action_type = queued_action_type( + queued_data, + latest_session_info, + working_dir, + reset_to_message_id, + ); let action = ExecutorAction::new(action_type, cleanup_action.map(Box::new)); @@ -1195,6 +1210,7 @@ impl CliExecutorDispatcher for LocalContainerService { session: &Session, prompt: &str, executor_config: &executors::profile::ExecutorConfig, + retry: Option<&RetryDispatchContext>, ) -> anyhow::Result { let workspace = Workspace::find_by_id(&self.db.pool, session.workspace_id) .await? @@ -1203,8 +1219,22 @@ impl CliExecutorDispatcher for LocalContainerService { message: prompt.to_string(), executor_config: executor_config.clone(), }; + if let Some(retry) = retry { + self.reset_session_to_process( + session.id, + retry.process_id, + retry.perform_git_reset, + retry.force_when_dirty, + ) + .await?; + } let process = self - .start_queued_follow_up(&workspace, session, &data) + .start_queued_follow_up( + &workspace, + session, + &data, + retry.and_then(|retry| retry.reset_to_message_id.clone()), + ) .await?; if let Err(error) = Scratch::delete(&self.db.pool, session.id, &ScratchType::DraftFollowUp).await @@ -1749,3 +1779,35 @@ fn success_exit_status() -> std::process::ExitStatus { ExitStatusExt::from_raw(0) } } + +#[cfg(test)] +mod tests { + use executors::{executors::BaseCodingAgent, profile::ExecutorConfig}; + + use super::*; + + #[test] + fn retry_threads_transcript_truncation_into_follow_up_action() { + let reset_to_message_id = "assistant-message-to-retry".to_string(); + let action = queued_action_type( + &DraftFollowUpData { + message: "retry this prompt".to_string(), + executor_config: ExecutorConfig::new(BaseCodingAgent::ClaudeCode), + }, + Some(CodingAgentResumeInfo { + session_id: "claude-session".to_string(), + message_id: Some(reset_to_message_id.clone()), + }), + None, + Some(reset_to_message_id.clone()), + ); + + let ExecutorActionType::CodingAgentFollowUpRequest(request) = action else { + panic!("a retry with session continuity must be a follow-up"); + }; + assert_eq!( + request.reset_to_message_id.as_deref(), + Some(reset_to_message_id.as_str()) + ); + } +} diff --git a/crates/server/src/routes/sessions/mod.rs b/crates/server/src/routes/sessions/mod.rs index abc30009f97..97d144ac46d 100644 --- a/crates/server/src/routes/sessions/mod.rs +++ b/crates/server/src/routes/sessions/mod.rs @@ -13,6 +13,7 @@ use axum::{ }; use db::models::{ cli_native_record::CliNativeRecord, + coding_agent_turn::CodingAgentTurn, execution_process::{ExecutionProcess, ExecutionProcessRunReason}, requests::UpdateSession, scratch::{Scratch, ScratchType}, @@ -28,7 +29,10 @@ use executors::{ profile::ExecutorConfig, }; use serde::Deserialize; -use services::services::{cli_collab::DispatchOutcome, container::ContainerService}; +use services::services::{ + cli_collab::{DispatchOutcome, RetryDispatchContext}, + container::ContainerService, +}; use ts_rs::TS; use utils::response::ApiResponse; use uuid::Uuid; @@ -187,26 +191,45 @@ pub async fn follow_up( .await?; } - if let Some(proc_id) = payload.retry_process_id { - let force_when_dirty = payload.force_when_dirty.unwrap_or(false); - let perform_git_reset = payload.perform_git_reset.unwrap_or(true); - deployment - .container() - .reset_session_to_process(session.id, proc_id, perform_git_reset, force_when_dirty) - .await?; - } + let retry = if let Some(process_id) = payload.retry_process_id { + let reset_to_message_id = CodingAgentTurn::find_latest_session_info(pool, session.id) + .await? + .and_then(|info| info.message_id); + Some(RetryDispatchContext { + process_id, + force_when_dirty: payload.force_when_dirty.unwrap_or(false), + perform_git_reset: payload.perform_git_reset.unwrap_or(true), + reset_to_message_id, + }) + } else { + None + }; - let outcome = match deployment - .cli_collab() - .dispatch_gate( - &session, - payload.prompt, - payload.executor_config, - QueuedMessageSource::Ui, - payload.replace, - ) - .await - { + let dispatch = if let Some(retry) = retry { + deployment + .cli_collab() + .dispatch_retry( + &session, + payload.prompt, + payload.executor_config, + QueuedMessageSource::Ui, + payload.replace, + retry, + ) + .await + } else { + deployment + .cli_collab() + .dispatch_gate( + &session, + payload.prompt, + payload.executor_config, + QueuedMessageSource::Ui, + payload.replace, + ) + .await + }; + let outcome = match dispatch { Ok(outcome) => outcome, Err(error) => { tracing::warn!(?error, session_id = %session.id, "follow-up dispatch gate failed closed"); diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index e0ad2b87253..427de405f08 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -103,9 +103,18 @@ pub trait CliExecutorDispatcher: Send + Sync { session: &Session, prompt: &str, executor_config: &ExecutorConfig, + retry: Option<&RetryDispatchContext>, ) -> AnyhowResult; } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RetryDispatchContext { + pub process_id: Uuid, + pub force_when_dirty: bool, + pub perform_git_reset: bool, + pub reset_to_message_id: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum WriterLease { Executor, @@ -334,11 +343,51 @@ impl CliCollabService { executor_config: ExecutorConfig, source: QueuedMessageSource, replace: bool, + ) -> Result { + self.dispatch_gate_with_context(session, prompt, executor_config, source, replace, None) + .await + } + + pub async fn dispatch_retry( + &self, + session: &Session, + prompt: String, + executor_config: ExecutorConfig, + source: QueuedMessageSource, + replace: bool, + retry: RetryDispatchContext, + ) -> Result { + self.dispatch_gate_with_context( + session, + prompt, + executor_config, + source, + replace, + Some(retry), + ) + .await + } + + async fn dispatch_gate_with_context( + &self, + session: &Session, + prompt: String, + executor_config: ExecutorConfig, + source: QueuedMessageSource, + replace: bool, + dispatch_context: Option, ) -> Result { let lock = self.session_lock(session.id).await; let _guard = lock.lock().await; let existing = self - .prepare_existing_slot(session.id, &prompt, &executor_config, source, replace) + .prepare_existing_slot( + session.id, + &prompt, + &executor_config, + dispatch_context.as_ref(), + source, + replace, + ) .await?; if let PreparedSlot::Conflict(status) = existing { return Ok(DispatchOutcome::Conflict { status }); @@ -352,14 +401,32 @@ impl CliCollabService { let lease = self.derive_lease_locked(session).await; let outcome = match lease { WriterLease::Executor | WriterLease::CliAmbiguous | WriterLease::Busy => { - self.queue_or_status(session.id, &prompt, &executor_config, source, existing) - .await? + self.queue_or_status( + session.id, + &prompt, + &executor_config, + dispatch_context.as_ref(), + source, + existing, + ) + .await? + } + WriterLease::Cli { .. } if dispatch_context.is_some() => { + self.queue_or_status( + session.id, + &prompt, + &executor_config, + dispatch_context.as_ref(), + source, + existing, + ) + .await? } WriterLease::Cli { claude_session_id } if !self.routing_disabled => { let row = match existing { Some(row) => row, None => { - self.store_slot(session.id, &prompt, &executor_config, source, false) + self.store_slot(session.id, &prompt, &executor_config, None, source, false) .await? } }; @@ -367,12 +434,26 @@ impl CliCollabService { .await? } WriterLease::Cli { .. } => { - self.queue_or_status(session.id, &prompt, &executor_config, source, existing) - .await? + self.queue_or_status( + session.id, + &prompt, + &executor_config, + dispatch_context.as_ref(), + source, + existing, + ) + .await? } WriterLease::Free => { - self.dispatch_executor_locked(session, &prompt, &executor_config, source, existing) - .await? + self.dispatch_executor_locked( + session, + &prompt, + &executor_config, + dispatch_context.as_ref(), + source, + existing, + ) + .await? } }; if matches!(outcome, DispatchOutcome::Queued { .. }) { @@ -392,7 +473,7 @@ impl CliCollabService { let lock = self.session_lock(session.id).await; let _guard = lock.lock().await; let result = self - .store_slot_result(session.id, &prompt, &executor_config, source, replace) + .store_slot_result(session.id, &prompt, &executor_config, None, source, replace) .await?; let outcome = match result { StoreQueuedMessageResult::Stored(row) => { @@ -439,6 +520,7 @@ impl CliCollabService { session_id: Uuid, prompt: &str, executor_config: &ExecutorConfig, + dispatch_context: Option<&RetryDispatchContext>, source: QueuedMessageSource, replace: bool, ) -> Result { @@ -450,8 +532,15 @@ impl CliCollabService { return Ok(PreparedSlot::Conflict(Self::status_from_row(active)?)); } Ok(PreparedSlot::Stored( - self.store_slot(session_id, prompt, executor_config, source, true) - .await?, + self.store_slot( + session_id, + prompt, + executor_config, + dispatch_context, + source, + true, + ) + .await?, )) } @@ -460,14 +549,22 @@ impl CliCollabService { session_id: Uuid, prompt: &str, executor_config: &ExecutorConfig, + dispatch_context: Option<&RetryDispatchContext>, source: QueuedMessageSource, existing: Option, ) -> Result { let row = match existing { Some(row) => row, None => { - self.store_slot(session_id, prompt, executor_config, source, false) - .await? + self.store_slot( + session_id, + prompt, + executor_config, + dispatch_context, + source, + false, + ) + .await? } }; Ok(DispatchOutcome::Queued { @@ -480,11 +577,19 @@ impl CliCollabService { session_id: Uuid, prompt: &str, executor_config: &ExecutorConfig, + dispatch_context: Option<&RetryDispatchContext>, source: QueuedMessageSource, replace: bool, ) -> Result { match self - .store_slot_result(session_id, prompt, executor_config, source, replace) + .store_slot_result( + session_id, + prompt, + executor_config, + dispatch_context, + source, + replace, + ) .await? { StoreQueuedMessageResult::Stored(row) => Ok(row), @@ -497,15 +602,18 @@ impl CliCollabService { session_id: Uuid, prompt: &str, executor_config: &ExecutorConfig, + dispatch_context: Option<&RetryDispatchContext>, source: QueuedMessageSource, replace: bool, ) -> Result { let config = serde_json::to_string(executor_config)?; - Ok(SessionQueuedMessage::store( + let dispatch_context = dispatch_context.map(serde_json::to_string).transpose()?; + Ok(SessionQueuedMessage::store_with_context( &self.db.pool, session_id, prompt, Some(&config), + dispatch_context.as_deref(), source, replace, ) @@ -551,6 +659,7 @@ impl CliCollabService { session: &Session, prompt: &str, executor_config: &ExecutorConfig, + dispatch_context: Option<&RetryDispatchContext>, source: QueuedMessageSource, existing: Option, ) -> Result { @@ -564,13 +673,27 @@ impl CliCollabService { Ok(Some(reservation)) => reservation, Ok(None) => { return self - .queue_or_status(session.id, prompt, executor_config, source, existing) + .queue_or_status( + session.id, + prompt, + executor_config, + dispatch_context, + source, + existing, + ) .await; } Err(error) => { tracing::warn!(?error, session_id = %session.id, "spawn reservation failed closed"); return self - .queue_or_status(session.id, prompt, executor_config, source, existing) + .queue_or_status( + session.id, + prompt, + executor_config, + dispatch_context, + source, + existing, + ) .await; } }; @@ -602,7 +725,7 @@ impl CliCollabService { let dispatched = self .dispatcher - .dispatch(session, prompt, executor_config) + .dispatch(session, prompt, executor_config, dispatch_context) .await; if let Err(error) = WorkspaceSpawnReservation::release( &self.db.pool, @@ -642,8 +765,15 @@ impl CliCollabService { status: self.status(session.id).await?, }) } else { - self.queue_or_status(session.id, prompt, executor_config, source, None) - .await + self.queue_or_status( + session.id, + prompt, + executor_config, + dispatch_context, + source, + None, + ) + .await } } } @@ -854,8 +984,27 @@ impl CliCollabService { return Ok(false); } }; + let dispatch_context = match row + .dispatch_context + .as_deref() + .map(serde_json::from_str::) + .transpose() + { + Ok(context) => context, + Err(error) => { + SessionQueuedMessage::set_failure_reason( + &self.db.pool, + row.id, + "queued message has invalid dispatch context", + ) + .await?; + tracing::warn!(?error, queue_id = %row.id, "invalid durable queue dispatch context"); + return Ok(false); + } + }; match self.derive_lease_locked(&session).await { WriterLease::Executor | WriterLease::CliAmbiguous | WriterLease::Busy => Ok(false), + WriterLease::Cli { .. } if dispatch_context.is_some() => Ok(false), WriterLease::Cli { claude_session_id } => { if self.original_paste_binding_is_active(&row).await? { return Ok(false); @@ -873,6 +1022,7 @@ impl CliCollabService { &session, &prompt, &executor_config, + dispatch_context.as_ref(), source, Some(row), ) @@ -1330,6 +1480,7 @@ mod tests { _session: &Session, _prompt: &str, _executor_config: &ExecutorConfig, + _retry: Option<&RetryDispatchContext>, ) -> AnyhowResult { panic!("lease-only tests must not dispatch") } @@ -1338,6 +1489,7 @@ mod tests { #[derive(Debug, Default)] struct DispatcherState { prompts: Vec, + retry_contexts: Vec>, reservation_seen: bool, } @@ -1353,6 +1505,7 @@ mod tests { session: &Session, prompt: &str, _executor_config: &ExecutorConfig, + retry: Option<&RetryDispatchContext>, ) -> AnyhowResult { let reservation_seen = WorkspaceSpawnReservation::find(&self.db.pool, session.workspace_id) @@ -1361,6 +1514,7 @@ mod tests { { let mut state = self.state.lock().unwrap(); state.prompts.push(prompt.to_string()); + state.retry_contexts.push(retry.cloned()); state.reservation_seen = reservation_seen; } Ok(create_running_executor(&self.db, session.id, prompt).await) @@ -1386,6 +1540,7 @@ mod tests { session: &Session, prompt: &str, _executor_config: &ExecutorConfig, + _retry: Option<&RetryDispatchContext>, ) -> AnyhowResult { self.calls.fetch_add(1, Ordering::SeqCst); self.started.notify_one(); @@ -1401,6 +1556,7 @@ mod tests { session: &Session, prompt: &str, _executor_config: &ExecutorConfig, + _retry: Option<&RetryDispatchContext>, ) -> AnyhowResult { let reservation_seen = WorkspaceSpawnReservation::find(&self.db.pool, session.workspace_id) @@ -2053,6 +2209,94 @@ mod tests { assert_eq!(queued.prompt, "preserve failed dispatch"); } + #[tokio::test] + async fn retry_conflict_never_invokes_destructive_dispatcher() { + let (db, _workspace, session) = fixture().await; + store_message(&db, session.id, "already queued", QueuedMessageSource::Ui).await; + let dispatcher_state = Arc::new(StdMutex::new(DispatcherState::default())); + let (service, _) = service_with_components( + db.clone(), + report(false, Some(false), SidEvidence::Unknown), + Arc::new(FakeTransport), + Arc::new(RecordingDispatcher { + db, + state: dispatcher_state.clone(), + }), + ); + let retry = RetryDispatchContext { + process_id: Uuid::new_v4(), + force_when_dirty: false, + perform_git_reset: true, + reset_to_message_id: Some("assistant-message".to_string()), + }; + + let outcome = service + .dispatch_retry( + &session, + "retry prompt".to_string(), + executor_config(), + QueuedMessageSource::Ui, + false, + retry, + ) + .await + .unwrap(); + + assert!(matches!(outcome, DispatchOutcome::Conflict { .. })); + assert!(dispatcher_state.lock().unwrap().prompts.is_empty()); + } + + #[tokio::test] + async fn queued_retry_preserves_reset_and_transcript_context_until_dispatch() { + let (db, _workspace, session) = fixture().await; + let blocker = create_running_executor(&db, session.id, "block retry").await; + let dispatcher_state = Arc::new(StdMutex::new(DispatcherState::default())); + let (service, _) = service_with_components( + db.clone(), + report(false, Some(false), SidEvidence::Unknown), + Arc::new(FakeTransport), + Arc::new(RecordingDispatcher { + db, + state: dispatcher_state.clone(), + }), + ); + let retry = RetryDispatchContext { + process_id: Uuid::new_v4(), + force_when_dirty: true, + perform_git_reset: true, + reset_to_message_id: Some("assistant-message".to_string()), + }; + + let outcome = service + .dispatch_retry( + &session, + "retry prompt".to_string(), + executor_config(), + QueuedMessageSource::Ui, + false, + retry.clone(), + ) + .await + .unwrap(); + + assert!(matches!(outcome, DispatchOutcome::Queued { .. })); + assert!(dispatcher_state.lock().unwrap().retry_contexts.is_empty()); + + ExecutionProcess::update_completion( + &service.db.pool, + blocker.id, + ExecutionProcessStatus::Completed, + Some(0), + ) + .await + .unwrap(); + assert!(service.drain_session(session.id).await.unwrap()); + assert_eq!( + dispatcher_state.lock().unwrap().retry_contexts, + [Some(retry)] + ); + } + #[tokio::test] async fn slow_executor_dispatch_is_not_reconciled_or_run_twice() { let (db, _workspace, session) = fixture().await; diff --git a/packages/web-core/src/features/workspace-chat/model/hooks/useMessageEditRetry.ts b/packages/web-core/src/features/workspace-chat/model/hooks/useMessageEditRetry.ts index 83f40cd1c88..d4b54f1e7d7 100644 --- a/packages/web-core/src/features/workspace-chat/model/hooks/useMessageEditRetry.ts +++ b/packages/web-core/src/features/workspace-chat/model/hooks/useMessageEditRetry.ts @@ -1,5 +1,9 @@ import { useMutation } from '@tanstack/react-query'; import { sessionsApi } from '@/shared/lib/api'; +import { + DispatchReplacementCancelledError, + dispatchWithConflictResolution, +} from '@/shared/lib/dispatchWithConflictResolution'; import { RestoreLogsDialog, type RestoreLogsDialogResult, @@ -8,6 +12,7 @@ import type { RepoBranchStatus, ExecutionProcess, ExecutorConfig, + QueueStatus, } from 'shared/types'; export interface MessageEditRetryParams { @@ -28,7 +33,8 @@ class EditDialogCancelledError extends Error { export function useMessageEditRetry( sessionId: string, onSuccess?: () => void, - onError?: (err: unknown) => void + onError?: (err: unknown) => void, + confirmReplacement?: (status: QueueStatus) => Promise ) { return useMutation({ mutationFn: async ({ @@ -53,22 +59,30 @@ export function useMessageEditRetry( throw new EditDialogCancelledError(); } - // Send the retry request with the edited message - await sessionsApi.followUp(sessionId, { - prompt: message, - executor_config: executorConfig, - retry_process_id: executionProcessId, - force_when_dirty: modalResult.forceWhenDirty ?? false, - perform_git_reset: modalResult.performGitReset ?? true, - replace: false, - }); + // A conflict is an explicit outcome, not a successful retry. Keep the + // destructive reset behind the server gate and ask before replacement. + await dispatchWithConflictResolution( + (replace) => + sessionsApi.followUp(sessionId, { + prompt: message, + executor_config: executorConfig, + retry_process_id: executionProcessId, + force_when_dirty: modalResult.forceWhenDirty ?? false, + perform_git_reset: modalResult.performGitReset ?? true, + replace, + }), + confirmReplacement + ); }, onSuccess: () => { onSuccess?.(); }, onError: (err) => { // Don't report cancellation as an error - if (err instanceof EditDialogCancelledError) { + if ( + err instanceof EditDialogCancelledError || + err instanceof DispatchReplacementCancelledError + ) { return; } console.error('Failed to send edited message:', err); diff --git a/packages/web-core/src/features/workspace-chat/ui/SessionChatBoxContainer.tsx b/packages/web-core/src/features/workspace-chat/ui/SessionChatBoxContainer.tsx index e2d7c350ce0..6e264ad0ef8 100644 --- a/packages/web-core/src/features/workspace-chat/ui/SessionChatBoxContainer.tsx +++ b/packages/web-core/src/features/workspace-chat/ui/SessionChatBoxContainer.tsx @@ -78,6 +78,7 @@ import { type DispatchNoticeKind, } from '../model/collaborationUiState'; import { useTransientToast } from '@/shared/hooks/useTransientToast'; +import { DispatchConflictError } from '@/shared/lib/dispatchWithConflictResolution'; /** Compute execution status from boolean flags */ function computeExecutionStatus(params: { @@ -795,12 +796,22 @@ export function SessionChatBoxContainer(props: SessionChatBoxContainerProps) { ]); // Message edit retry mutation - const editRetryMutation = useMessageEditRetry(sessionId ?? '', () => { - // On success, clear edit mode and reset editor - editContext.cancelEdit(); - cancelDebouncedSave(); - setLocalMessage(''); - }); + const editRetryMutation = useMessageEditRetry( + sessionId ?? '', + () => { + // On success, clear edit mode and reset editor + editContext.cancelEdit(); + cancelDebouncedSave(); + setLocalMessage(''); + }, + (error) => { + if (error instanceof DispatchConflictError) { + setQueueStatus(error.status); + showReplacementConflict(error.status); + } + }, + confirmQueueReplacement + ); const areAttachmentInputsDisabled = mode === 'placeholder' || diff --git a/packages/web-core/src/shared/components/NormalizedConversation/RetryEditorInline.tsx b/packages/web-core/src/shared/components/NormalizedConversation/RetryEditorInline.tsx index 18f373d9c19..dd073e80964 100644 --- a/packages/web-core/src/shared/components/NormalizedConversation/RetryEditorInline.tsx +++ b/packages/web-core/src/shared/components/NormalizedConversation/RetryEditorInline.tsx @@ -15,6 +15,8 @@ import { useVariant } from '@/shared/hooks/useVariant'; import { useRetryProcess } from '@/shared/hooks/useRetryProcess'; import { executorConfigFromAction } from '@/shared/lib/executor'; import { buildWorkspaceAttachmentMarkdown } from '@/shared/lib/workspaceAttachments'; +import { ConfirmDialog } from '@vibe/ui/components/ConfirmDialog'; +import type { QueueStatus } from 'shared/types'; export function RetryEditorInline({ attempt, @@ -27,7 +29,7 @@ export function RetryEditorInline({ initialContent: string; onCancelled?: () => void; }) { - const { t } = useTranslation(['common']); + const { t } = useTranslation(['common', 'tasks']); const workspaceId = attempt.id; const { isAttemptRunning, attemptData } = useWorkspaceExecution(workspaceId); const { data: branchStatus } = useBranchStatus(workspaceId); @@ -53,10 +55,33 @@ export function RetryEditorInline({ scratchVariant: undefined, }); + const confirmQueueReplacement = useCallback( + async (status: QueueStatus) => { + if (status.status === 'empty') return false; + const result = await ConfirmDialog.show({ + title: t('tasks:conversation.queue.replaceTitle'), + message: + status.message.source === 'recovery' + ? t('tasks:conversation.queue.replaceRecoveryMessage', { + message: status.message.data.message, + }) + : t('tasks:conversation.queue.replaceUiMessage', { + message: status.message.data.message, + }), + confirmText: t('tasks:conversation.queue.replaceConfirm'), + cancelText: t('tasks:conversation.actions.cancel'), + variant: 'destructive', + }); + return result === 'confirmed'; + }, + [t] + ); + const retryMutation = useRetryProcess( sessionId ?? '', () => onCancelled?.(), - (err) => setSendError((err as Error)?.message || 'Failed to send retry') + (err) => setSendError((err as Error)?.message || 'Failed to send retry'), + confirmQueueReplacement ); const isSending = retryMutation.isPending; diff --git a/packages/web-core/src/shared/dialogs/tasks/ResolveConflictsDialog.tsx b/packages/web-core/src/shared/dialogs/tasks/ResolveConflictsDialog.tsx index daf131a9821..ee9035fb3a5 100644 --- a/packages/web-core/src/shared/dialogs/tasks/ResolveConflictsDialog.tsx +++ b/packages/web-core/src/shared/dialogs/tasks/ResolveConflictsDialog.tsx @@ -24,6 +24,8 @@ import { defineModal } from '@/shared/lib/modals'; import { buildResolveConflictsInstructions } from '@/shared/lib/conflicts'; import { useExecutionProcesses } from '@/shared/hooks/useExecutionProcesses'; import { getLatestConfigFromProcesses } from '@/shared/lib/executor'; +import { dispatchWithConflictResolution } from '@/shared/lib/dispatchWithConflictResolution'; +import { ConfirmDialog } from '@vibe/ui/components/ConfirmDialog'; import type { BaseCodingAgent, ExecutorProfileId, @@ -164,18 +166,40 @@ const ResolveConflictsDialogImpl = create( return; } - // Send follow-up with conflict resolution instructions - await sessionsApi.followUp(targetSessionId, { - prompt: conflictInstructions, - executor_config: { - executor: effectiveProfile.executor, - variant: effectiveProfile.variant, - }, - retry_process_id: null, - force_when_dirty: null, - perform_git_reset: null, - replace: false, - }); + // Send follow-up with conflict resolution instructions. An occupied + // durable slot requires explicit replacement instead of false success. + await dispatchWithConflictResolution( + (replace) => + sessionsApi.followUp(targetSessionId!, { + prompt: conflictInstructions, + executor_config: { + executor: effectiveProfile.executor, + variant: effectiveProfile.variant, + }, + retry_process_id: null, + force_when_dirty: null, + perform_git_reset: null, + replace, + }), + async (status) => { + if (status.status === 'empty') return false; + const result = await ConfirmDialog.show({ + title: t('conversation.queue.replaceTitle'), + message: + status.message.source === 'recovery' + ? t('conversation.queue.replaceRecoveryMessage', { + message: status.message.data.message, + }) + : t('conversation.queue.replaceUiMessage', { + message: status.message.data.message, + }), + confirmText: t('conversation.queue.replaceConfirm'), + cancelText: t('conversation.actions.cancel'), + variant: 'destructive', + }); + return result === 'confirmed'; + } + ); // Invalidate queries and wait for them to complete await Promise.all([ diff --git a/packages/web-core/src/shared/hooks/useRetryProcess.ts b/packages/web-core/src/shared/hooks/useRetryProcess.ts index f08c58a8c68..94a5abafbca 100644 --- a/packages/web-core/src/shared/hooks/useRetryProcess.ts +++ b/packages/web-core/src/shared/hooks/useRetryProcess.ts @@ -1,5 +1,9 @@ import { useMutation } from '@tanstack/react-query'; import { sessionsApi } from '@/shared/lib/api'; +import { + DispatchReplacementCancelledError, + dispatchWithConflictResolution, +} from '@/shared/lib/dispatchWithConflictResolution'; import { RestoreLogsDialog, type RestoreLogsDialogResult, @@ -8,6 +12,7 @@ import type { RepoBranchStatus, ExecutionProcess, BaseCodingAgent, + QueueStatus, } from 'shared/types'; export interface RetryProcessParams { @@ -29,7 +34,8 @@ class RetryDialogCancelledError extends Error { export function useRetryProcess( sessionId: string, onSuccess?: () => void, - onError?: (err: unknown) => void + onError?: (err: unknown) => void, + confirmReplacement?: (status: QueueStatus) => Promise ) { return useMutation({ mutationFn: async ({ @@ -55,22 +61,28 @@ export function useRetryProcess( throw new RetryDialogCancelledError(); } - // Send the retry request - await sessionsApi.followUp(sessionId, { - prompt: message, - executor_config: { executor, variant }, - retry_process_id: executionProcessId, - force_when_dirty: modalResult.forceWhenDirty ?? false, - perform_git_reset: modalResult.performGitReset ?? true, - replace: false, - }); + await dispatchWithConflictResolution( + (replace) => + sessionsApi.followUp(sessionId, { + prompt: message, + executor_config: { executor, variant }, + retry_process_id: executionProcessId, + force_when_dirty: modalResult.forceWhenDirty ?? false, + perform_git_reset: modalResult.performGitReset ?? true, + replace, + }), + confirmReplacement + ); }, onSuccess: () => { onSuccess?.(); }, onError: (err) => { // Don't report cancellation as an error - if (err instanceof RetryDialogCancelledError) { + if ( + err instanceof RetryDialogCancelledError || + err instanceof DispatchReplacementCancelledError + ) { return; } console.error('Failed to send retry:', err); diff --git a/packages/web-core/src/shared/lib/dispatchWithConflictResolution.test.ts b/packages/web-core/src/shared/lib/dispatchWithConflictResolution.test.ts new file mode 100644 index 00000000000..918d5670a66 --- /dev/null +++ b/packages/web-core/src/shared/lib/dispatchWithConflictResolution.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { DispatchOutcome, QueueStatus } from 'shared/types'; + +import { + DispatchConflictError, + DispatchReplacementCancelledError, + dispatchWithConflictResolution, +} from './dispatchWithConflictResolution'; + +const conflictStatus = { + status: 'queued', + message: { data: { message: 'existing queued prompt' } }, +} as QueueStatus; + +describe('dispatchWithConflictResolution', () => { + it('does not report success when replacement is declined', async () => { + const send = vi + .fn<(_: boolean) => Promise>() + .mockResolvedValue({ + outcome: 'conflict', + status: conflictStatus, + }); + + await expect( + dispatchWithConflictResolution(send, async () => false) + ).rejects.toBeInstanceOf(DispatchReplacementCancelledError); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith(false); + }); + + it('retries only after explicit replacement confirmation', async () => { + const accepted = { + outcome: 'queued', + status: conflictStatus, + } as DispatchOutcome; + const send = vi + .fn<(_: boolean) => Promise>() + .mockResolvedValueOnce({ outcome: 'conflict', status: conflictStatus }) + .mockResolvedValueOnce(accepted); + + await expect( + dispatchWithConflictResolution(send, async () => true) + ).resolves.toBe(accepted); + expect(send.mock.calls).toEqual([[false], [true]]); + }); + + it('surfaces a second conflict instead of resolving successfully', async () => { + const send = vi + .fn<(_: boolean) => Promise>() + .mockResolvedValue({ + outcome: 'conflict', + status: conflictStatus, + }); + + await expect( + dispatchWithConflictResolution(send, async () => true) + ).rejects.toBeInstanceOf(DispatchConflictError); + }); +}); diff --git a/packages/web-core/src/shared/lib/dispatchWithConflictResolution.ts b/packages/web-core/src/shared/lib/dispatchWithConflictResolution.ts new file mode 100644 index 00000000000..2cc01a62d41 --- /dev/null +++ b/packages/web-core/src/shared/lib/dispatchWithConflictResolution.ts @@ -0,0 +1,33 @@ +import type { DispatchOutcome, QueueStatus } from 'shared/types'; + +export class DispatchConflictError extends Error { + constructor(public readonly status: QueueStatus) { + super('The queued message changed before this request could be accepted'); + this.name = 'DispatchConflictError'; + } +} + +export class DispatchReplacementCancelledError extends Error { + constructor() { + super('Queued message replacement was cancelled'); + this.name = 'DispatchReplacementCancelledError'; + } +} + +export async function dispatchWithConflictResolution( + send: (replace: boolean) => Promise, + confirmReplacement?: (status: QueueStatus) => Promise +): Promise { + const first = await send(false); + if (first.outcome !== 'conflict') return first; + if (!confirmReplacement) throw new DispatchConflictError(first.status); + if (!(await confirmReplacement(first.status))) { + throw new DispatchReplacementCancelledError(); + } + + const replacement = await send(true); + if (replacement.outcome === 'conflict') { + throw new DispatchConflictError(replacement.status); + } + return replacement; +} From 5945c87ac80f9b36b78bd9b74d9979ee5fc81c64 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 00:01:54 +0000 Subject: [PATCH 24/36] fix(collab): hold queue after killed or failed agent --- ...59ec91e1810d6244e35e6d99762b3e0f5efb2.json | 20 +++ crates/db/src/models/execution_process.rs | 22 +++ crates/local-deployment/src/container.rs | 15 +- crates/services/src/services/cli_collab.rs | 131 +++++++++++++++++- 4 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 crates/db/.sqlx/query-a0a7355dfb03a06f5dc4fe5c3df59ec91e1810d6244e35e6d99762b3e0f5efb2.json diff --git a/crates/db/.sqlx/query-a0a7355dfb03a06f5dc4fe5c3df59ec91e1810d6244e35e6d99762b3e0f5efb2.json b/crates/db/.sqlx/query-a0a7355dfb03a06f5dc4fe5c3df59ec91e1810d6244e35e6d99762b3e0f5efb2.json new file mode 100644 index 00000000000..f650a8bea69 --- /dev/null +++ b/crates/db/.sqlx/query-a0a7355dfb03a06f5dc4fe5c3df59ec91e1810d6244e35e6d99762b3e0f5efb2.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "SELECT EXISTS(\n SELECT 1 FROM execution_processes ep\n WHERE ep.session_id = $1\n AND ep.run_reason = 'codingagent'\n AND ep.status IN ('failed', 'killed')\n AND julianday(ep.completed_at) >= julianday($2)\n ) AS \"exists!: bool\"", + "describe": { + "columns": [ + { + "name": "exists!: bool", + "ordinal": 0, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false + ] + }, + "hash": "a0a7355dfb03a06f5dc4fe5c3df59ec91e1810d6244e35e6d99762b3e0f5efb2" +} diff --git a/crates/db/src/models/execution_process.rs b/crates/db/src/models/execution_process.rs index c9f108bd1ef..23ef1a9e5ec 100644 --- a/crates/db/src/models/execution_process.rs +++ b/crates/db/src/models/execution_process.rs @@ -289,6 +289,28 @@ impl ExecutionProcess { Ok(count > 0) } + /// Whether a failed or killed coding-agent writer completed after the + /// queued row's last user-authorized update. + pub async fn has_abnormal_coding_agent_completion_after( + pool: &SqlitePool, + session_id: Uuid, + after: DateTime, + ) -> Result { + sqlx::query_scalar!( + r#"SELECT EXISTS( + SELECT 1 FROM execution_processes ep + WHERE ep.session_id = $1 + AND ep.run_reason = 'codingagent' + AND ep.status IN ('failed', 'killed') + AND julianday(ep.completed_at) >= julianday($2) + ) AS "exists!: bool""#, + session_id, + after + ) + .fetch_one(pool) + .await + } + /// Check if there are running processes (excluding dev servers) for a workspace (across all sessions) pub async fn has_running_non_dev_server_processes_for_workspace( pool: &SqlitePool, diff --git a/crates/local-deployment/src/container.rs b/crates/local-deployment/src/container.rs index 476bc389c9e..030f2c2cdf3 100644 --- a/crates/local-deployment/src/container.rs +++ b/crates/local-deployment/src/container.rs @@ -766,7 +766,13 @@ impl LocalContainerService { .is_some(); let started_queued_follow_up = match container.cli_collab.get() { Some(cli_collab) => { - match cli_collab.on_executor_finished(ctx.session.id).await { + match cli_collab + .on_executor_finished( + ctx.session.id, + ctx.execution_process.status.clone(), + ) + .await + { Ok(started) => started, Err(error) => { tracing::error!( @@ -822,7 +828,12 @@ impl LocalContainerService { if !has_running_agent && let Some(cli_collab) = container.cli_collab.get() - && let Err(error) = cli_collab.on_executor_finished(ctx.session.id).await + && let Err(error) = cli_collab + .on_executor_finished( + ctx.session.id, + ctx.execution_process.status.clone(), + ) + .await { tracing::error!( ?error, diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index 427de405f08..d1258416d2d 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -14,7 +14,7 @@ use db::{ claude_session_link::ClaudeSessionLink, cli_pane_binding::CliPaneBinding, coding_agent_turn::CodingAgentTurn, - execution_process::{ExecutionProcess, ExecutionProcessRunReason}, + execution_process::{ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus}, session::Session, session_queued_message::{ QueuedMessageSource, QueuedMessageState, SessionQueuedMessage, StoreQueuedMessageResult, @@ -41,6 +41,8 @@ const RESUME_SIGNAL_ATTEMPTS: u32 = 3; const RESUME_SIGNAL_RETRY_DELAY: Duration = Duration::from_millis(100); const PASTING_STARTUP_GRACE: ChronoDuration = ChronoDuration::seconds(5); const PASTE_ACK_HARD_CAP: ChronoDuration = ChronoDuration::minutes(15); +const ABNORMAL_EXECUTOR_QUEUE_HOLD: &str = + "previous agent failed or was killed; confirm Send again to run this message"; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SidEvidence { @@ -783,9 +785,25 @@ impl CliCollabService { Ok(QueueStatus::from_message(QueuedMessage::try_from(row)?)) } - pub async fn on_executor_finished(&self, session_id: Uuid) -> Result { + pub async fn on_executor_finished( + &self, + session_id: Uuid, + finished_status: ExecutionProcessStatus, + ) -> Result { let lock = self.session_lock(session_id).await; let guard = lock.lock().await; + if matches!( + finished_status, + ExecutionProcessStatus::Failed | ExecutionProcessStatus::Killed + ) { + if let Some(row) = SessionQueuedMessage::find_active(&self.db.pool, session_id).await? + && row.state == QueuedMessageState::Queued + { + self.hold_after_abnormal_writer(&row).await?; + } + self.notify.notify_one(); + return Ok(false); + } let session = Session::find_by_id(&self.db.pool, session_id) .await? .ok_or(CliCollabError::WorkspaceMissing(session_id))?; @@ -822,6 +840,30 @@ impl CliCollabService { Ok(started) } + async fn hold_after_abnormal_writer( + &self, + row: &SessionQueuedMessage, + ) -> Result { + if row.failure_reason.as_deref() == Some(ABNORMAL_EXECUTOR_QUEUE_HOLD) { + return Ok(true); + } + if !ExecutionProcess::has_abnormal_coding_agent_completion_after( + &self.db.pool, + row.session_id, + row.updated_at, + ) + .await? + { + return Ok(false); + } + Ok(SessionQueuedMessage::set_failure_reason( + &self.db.pool, + row.id, + ABNORMAL_EXECUTOR_QUEUE_HOLD, + ) + .await?) + } + async fn hold_or_resume_bound_pane_locked( &self, session: &Session, @@ -959,6 +1001,9 @@ impl CliCollabService { if row.state != QueuedMessageState::Queued { return Ok(false); } + if self.hold_after_abnormal_writer(&row).await? { + return Ok(false); + } let session = Session::find_by_id(&self.db.pool, session_id) .await? .ok_or(CliCollabError::WorkspaceMissing(session_id))?; @@ -1882,7 +1927,12 @@ mod tests { Arc::new(NeverDispatcher), ); - assert!(!service.on_executor_finished(session.id).await.unwrap()); + assert!( + !service + .on_executor_finished(session.id, ExecutionProcessStatus::Completed) + .await + .unwrap() + ); assert!( transport_state.lock().unwrap().signals.is_empty(), "a prior finish hook must not resume the pane over a newer executor" @@ -1896,7 +1946,12 @@ mod tests { ) .await .unwrap(); - assert!(!service.on_executor_finished(session.id).await.unwrap()); + assert!( + !service + .on_executor_finished(session.id, ExecutionProcessStatus::Completed) + .await + .unwrap() + ); assert_eq!( transport_state.lock().unwrap().signals, [(workspace.id, sid.to_string())] @@ -1917,6 +1972,55 @@ mod tests { assert_eq!(service.derive_lease(&session).await, WriterLease::Free); } + #[tokio::test] + async fn killed_executor_holds_queued_follow_up_until_explicit_retry() { + let (db, _workspace, session) = fixture().await; + let execution = create_running_executor(&db, session.id, "running writer").await; + let row = store_message( + &db, + session.id, + "do not run after kill", + QueuedMessageSource::Ui, + ) + .await; + ExecutionProcess::update_completion( + &db.pool, + execution.id, + ExecutionProcessStatus::Killed, + None, + ) + .await + .unwrap(); + let dispatcher_state = Arc::new(StdMutex::new(DispatcherState::default())); + let (service, _) = service_with_components( + db.clone(), + report(false, Some(false), SidEvidence::Unknown), + Arc::new(FakeTransport), + Arc::new(RecordingDispatcher { + db: db.clone(), + state: dispatcher_state.clone(), + }), + ); + + assert!( + !service + .on_executor_finished(session.id, ExecutionProcessStatus::Killed) + .await + .unwrap() + ); + assert!(!service.drain_session(session.id).await.unwrap()); + assert!(dispatcher_state.lock().unwrap().prompts.is_empty()); + let held = SessionQueuedMessage::find_by_id(&db.pool, row.id) + .await + .unwrap() + .unwrap(); + assert_eq!(held.state, QueuedMessageState::Queued); + assert_eq!( + held.failure_reason.as_deref(), + Some(ABNORMAL_EXECUTOR_QUEUE_HOLD) + ); + } + #[tokio::test] async fn drain_recovers_busy_resume_pane_when_finish_hook_was_missed() { let (db, workspace, session) = fixture().await; @@ -1978,7 +2082,12 @@ mod tests { Arc::new(NeverDispatcher), ); - assert!(!service.on_executor_finished(session.id).await.unwrap()); + assert!( + !service + .on_executor_finished(session.id, ExecutionProcessStatus::Completed) + .await + .unwrap() + ); assert_eq!( transport_state.lock().unwrap().signals, @@ -2014,7 +2123,10 @@ mod tests { Arc::new(NeverDispatcher), ); - let error = service.on_executor_finished(session.id).await.unwrap_err(); + let error = service + .on_executor_finished(session.id, ExecutionProcessStatus::Completed) + .await + .unwrap_err(); assert!(matches!(error, CliCollabError::Transport { .. })); assert_eq!( @@ -2374,7 +2486,12 @@ mod tests { ) .await .unwrap(); - assert!(!service.on_executor_finished(session.id).await.unwrap()); + assert!( + !service + .on_executor_finished(session.id, ExecutionProcessStatus::Completed) + .await + .unwrap() + ); assert_eq!(calls.load(Ordering::SeqCst), 1); } From bf6418bb839555ff1ac241905acffe9b7ff4a54f Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 00:07:26 +0000 Subject: [PATCH 25/36] fix(collab): centralize executor completion hook --- ...bf82a88806ee6ababdb437ab5e97ddd2122da.json | 20 ---- ...59ec91e1810d6244e35e6d99762b3e0f5efb2.json | 20 ---- ...2277ecbf4c47e75e60a794b9b0239d0410843.json | 80 ++++++++++++++++ crates/db/src/models/execution_process.rs | 59 ++++++++---- crates/local-deployment/src/container.rs | 94 ++++++++----------- crates/services/src/services/cli_collab.rs | 63 ++++++++++--- crates/services/src/services/container.rs | 18 +++- 7 files changed, 225 insertions(+), 129 deletions(-) delete mode 100644 crates/db/.sqlx/query-1b186dc075846fc1f7270a942afbf82a88806ee6ababdb437ab5e97ddd2122da.json delete mode 100644 crates/db/.sqlx/query-a0a7355dfb03a06f5dc4fe5c3df59ec91e1810d6244e35e6d99762b3e0f5efb2.json create mode 100644 crates/db/.sqlx/query-e90f71d7c508f43101474722d372277ecbf4c47e75e60a794b9b0239d0410843.json diff --git a/crates/db/.sqlx/query-1b186dc075846fc1f7270a942afbf82a88806ee6ababdb437ab5e97ddd2122da.json b/crates/db/.sqlx/query-1b186dc075846fc1f7270a942afbf82a88806ee6ababdb437ab5e97ddd2122da.json deleted file mode 100644 index b94782c589e..00000000000 --- a/crates/db/.sqlx/query-1b186dc075846fc1f7270a942afbf82a88806ee6ababdb437ab5e97ddd2122da.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT COUNT(*) as \"count!: i64\"\n FROM execution_processes ep\n WHERE ep.session_id = $1\n AND ep.status = 'running'\n AND ep.run_reason = 'codingagent'", - "describe": { - "columns": [ - { - "name": "count!: i64", - "ordinal": 0, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "1b186dc075846fc1f7270a942afbf82a88806ee6ababdb437ab5e97ddd2122da" -} diff --git a/crates/db/.sqlx/query-a0a7355dfb03a06f5dc4fe5c3df59ec91e1810d6244e35e6d99762b3e0f5efb2.json b/crates/db/.sqlx/query-a0a7355dfb03a06f5dc4fe5c3df59ec91e1810d6244e35e6d99762b3e0f5efb2.json deleted file mode 100644 index f650a8bea69..00000000000 --- a/crates/db/.sqlx/query-a0a7355dfb03a06f5dc4fe5c3df59ec91e1810d6244e35e6d99762b3e0f5efb2.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "SELECT EXISTS(\n SELECT 1 FROM execution_processes ep\n WHERE ep.session_id = $1\n AND ep.run_reason = 'codingagent'\n AND ep.status IN ('failed', 'killed')\n AND julianday(ep.completed_at) >= julianday($2)\n ) AS \"exists!: bool\"", - "describe": { - "columns": [ - { - "name": "exists!: bool", - "ordinal": 0, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false - ] - }, - "hash": "a0a7355dfb03a06f5dc4fe5c3df59ec91e1810d6244e35e6d99762b3e0f5efb2" -} diff --git a/crates/db/.sqlx/query-e90f71d7c508f43101474722d372277ecbf4c47e75e60a794b9b0239d0410843.json b/crates/db/.sqlx/query-e90f71d7c508f43101474722d372277ecbf4c47e75e60a794b9b0239d0410843.json new file mode 100644 index 00000000000..f1a51ef9ede --- /dev/null +++ b/crates/db/.sqlx/query-e90f71d7c508f43101474722d372277ecbf4c47e75e60a794b9b0239d0410843.json @@ -0,0 +1,80 @@ +{ + "db_name": "SQLite", + "query": "SELECT\n ep.id as \"id!: Uuid\",\n ep.session_id as \"session_id!: Uuid\",\n ep.run_reason as \"run_reason!: ExecutionProcessRunReason\",\n ep.executor_action as \"executor_action!: sqlx::types::Json\",\n ep.status as \"status!: ExecutionProcessStatus\",\n ep.exit_code,\n ep.dropped as \"dropped!: bool\",\n ep.started_at as \"started_at!: DateTime\",\n ep.completed_at as \"completed_at?: DateTime\",\n ep.created_at as \"created_at!: DateTime\",\n ep.updated_at as \"updated_at!: DateTime\"\n FROM execution_processes ep\n WHERE ep.session_id = $1 AND ep.status = 'running'", + "describe": { + "columns": [ + { + "name": "id!: Uuid", + "ordinal": 0, + "type_info": "Blob" + }, + { + "name": "session_id!: Uuid", + "ordinal": 1, + "type_info": "Blob" + }, + { + "name": "run_reason!: ExecutionProcessRunReason", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "executor_action!: sqlx::types::Json", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "status!: ExecutionProcessStatus", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "exit_code", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "dropped!: bool", + "ordinal": 6, + "type_info": "Integer" + }, + { + "name": "started_at!: DateTime", + "ordinal": 7, + "type_info": "Text" + }, + { + "name": "completed_at?: DateTime", + "ordinal": 8, + "type_info": "Text" + }, + { + "name": "created_at!: DateTime", + "ordinal": 9, + "type_info": "Text" + }, + { + "name": "updated_at!: DateTime", + "ordinal": 10, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + true, + false, + false, + false, + false, + true, + false, + false, + true, + false, + false + ] + }, + "hash": "e90f71d7c508f43101474722d372277ecbf4c47e75e60a794b9b0239d0410843" +} diff --git a/crates/db/src/models/execution_process.rs b/crates/db/src/models/execution_process.rs index 23ef1a9e5ec..620a18901c2 100644 --- a/crates/db/src/models/execution_process.rs +++ b/crates/db/src/models/execution_process.rs @@ -121,6 +121,18 @@ pub struct MissingBeforeContext { } impl ExecutionProcess { + pub fn is_active_writer(&self) -> bool { + self.run_reason == ExecutionProcessRunReason::CodingAgent + && self.status == ExecutionProcessStatus::Running + } + + pub fn writer_released_after(&self, after: DateTime) -> bool { + self.run_reason == ExecutionProcessRunReason::CodingAgent + && self + .completed_at + .is_some_and(|completed_at| completed_at >= after) + } + /// Find execution process by ID pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as!( @@ -276,17 +288,27 @@ impl ExecutionProcess { pool: &SqlitePool, session_id: Uuid, ) -> Result { - let count: i64 = sqlx::query_scalar!( - r#"SELECT COUNT(*) as "count!: i64" + let processes = sqlx::query_as!( + ExecutionProcess, + r#"SELECT + ep.id as "id!: Uuid", + ep.session_id as "session_id!: Uuid", + ep.run_reason as "run_reason!: ExecutionProcessRunReason", + ep.executor_action as "executor_action!: sqlx::types::Json", + ep.status as "status!: ExecutionProcessStatus", + ep.exit_code, + ep.dropped as "dropped!: bool", + ep.started_at as "started_at!: DateTime", + ep.completed_at as "completed_at?: DateTime", + ep.created_at as "created_at!: DateTime", + ep.updated_at as "updated_at!: DateTime" FROM execution_processes ep - WHERE ep.session_id = $1 - AND ep.status = 'running' - AND ep.run_reason = 'codingagent'"#, + WHERE ep.session_id = $1 AND ep.status = 'running'"#, session_id ) - .fetch_one(pool) + .fetch_all(pool) .await?; - Ok(count > 0) + Ok(processes.iter().any(Self::is_active_writer)) } /// Whether a failed or killed coding-agent writer completed after the @@ -296,19 +318,16 @@ impl ExecutionProcess { session_id: Uuid, after: DateTime, ) -> Result { - sqlx::query_scalar!( - r#"SELECT EXISTS( - SELECT 1 FROM execution_processes ep - WHERE ep.session_id = $1 - AND ep.run_reason = 'codingagent' - AND ep.status IN ('failed', 'killed') - AND julianday(ep.completed_at) >= julianday($2) - ) AS "exists!: bool""#, - session_id, - after - ) - .fetch_one(pool) - .await + Ok(Self::find_by_session_id(pool, session_id, true) + .await? + .iter() + .any(|process| { + process.writer_released_after(after) + && matches!( + process.status, + ExecutionProcessStatus::Failed | ExecutionProcessStatus::Killed + ) + })) } /// Check if there are running processes (excluding dev servers) for a workspace (across all sessions) diff --git a/crates/local-deployment/src/container.rs b/crates/local-deployment/src/container.rs index 030f2c2cdf3..1decaa0f973 100644 --- a/crates/local-deployment/src/container.rs +++ b/crates/local-deployment/src/container.rs @@ -754,38 +754,47 @@ impl LocalContainerService { } } + // The completion hook is invoked exactly once for every + // process. It self-guards chained actions, while setup + // completion remains eligible to release a queue only when no + // coding-agent writer was started next. + let should_finalize = skipped_cleanup || container.should_finalize(&ctx); + let releases_collaboration_writer = should_finalize + || matches!( + ctx.execution_process.run_reason, + ExecutionProcessRunReason::SetupScript + ); + let started_queued_follow_up = match container.cli_collab.get() { + Some(cli_collab) => match cli_collab + .on_executor_finished( + ctx.session.id, + ctx.execution_process.status.clone(), + releases_collaboration_writer, + ) + .await + { + Ok(started) => started, + Err(error) => { + tracing::error!( + ?error, + session_id = %ctx.session.id, + "CLI collaboration finish hook failed closed" + ); + false + } + }, + None => false, + }; + // A no-change coding turn bypasses its configured cleanup action, - // but it still releases the collaboration writer just like the - // normal final action in the chain. - if skipped_cleanup || container.should_finalize(&ctx) { + // but it still finalizes like the normal final action in the chain. + if should_finalize { let has_chained_follow_up = ctx .execution_process .executor_action() .ok() .and_then(|action| action.next_action()) .is_some(); - let started_queued_follow_up = match container.cli_collab.get() { - Some(cli_collab) => { - match cli_collab - .on_executor_finished( - ctx.session.id, - ctx.execution_process.status.clone(), - ) - .await - { - Ok(started) => started, - Err(error) => { - tracing::error!( - ?error, - session_id = %ctx.session.id, - "CLI collaboration finish hook failed closed" - ); - false - } - } - } - None => false, - }; if !started_queued_follow_up { container.finalize_task(&ctx).await; } @@ -812,37 +821,6 @@ impl LocalContainerService { } } - // When a parallel setup script finishes and no coding agent is running, - // consume any queued message that was stuck waiting - if matches!( - ctx.execution_process.run_reason, - ExecutionProcessRunReason::SetupScript - ) && !container.should_finalize(&ctx) - { - let has_running_agent = ExecutionProcess::has_running_coding_agent_for_session( - &db.pool, - ctx.session.id, - ) - .await - .unwrap_or(true); - - if !has_running_agent - && let Some(cli_collab) = container.cli_collab.get() - && let Err(error) = cli_collab - .on_executor_finished( - ctx.session.id, - ctx.execution_process.status.clone(), - ) - .await - { - tracing::error!( - ?error, - session_id = %ctx.session.id, - "CLI collaboration setup completion hook failed closed" - ); - } - } - // Fire analytics event when CodingAgent execution has finished if config.read().await.analytics_enabled && matches!( @@ -1291,6 +1269,10 @@ impl ContainerService for LocalContainerService { &self.notification_service } + fn cli_collab(&self) -> Option<&Arc> { + self.cli_collab.get() + } + async fn touch(&self, workspace: &Workspace) -> Result<(), ContainerError> { let now = Instant::now(); diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index d1258416d2d..244a316f54c 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -789,9 +789,13 @@ impl CliCollabService { &self, session_id: Uuid, finished_status: ExecutionProcessStatus, + releases_collaboration_writer: bool, ) -> Result { let lock = self.session_lock(session_id).await; let guard = lock.lock().await; + if !releases_collaboration_writer { + return Ok(false); + } if matches!( finished_status, ExecutionProcessStatus::Failed | ExecutionProcessStatus::Killed @@ -1263,12 +1267,9 @@ impl CliCollabService { continue; } }; - let writer_released_after_binding = processes.iter().any(|process| { - process.run_reason == ExecutionProcessRunReason::CodingAgent - && process - .completed_at - .is_some_and(|completed_at| completed_at >= binding.created_at) - }); + let writer_released_after_binding = processes + .iter() + .any(|process| process.writer_released_after(binding.created_at)); if !writer_released_after_binding { continue; } @@ -1929,7 +1930,7 @@ mod tests { assert!( !service - .on_executor_finished(session.id, ExecutionProcessStatus::Completed) + .on_executor_finished(session.id, ExecutionProcessStatus::Completed, true) .await .unwrap() ); @@ -1948,7 +1949,7 @@ mod tests { .unwrap(); assert!( !service - .on_executor_finished(session.id, ExecutionProcessStatus::Completed) + .on_executor_finished(session.id, ExecutionProcessStatus::Completed, true) .await .unwrap() ); @@ -2004,7 +2005,7 @@ mod tests { assert!( !service - .on_executor_finished(session.id, ExecutionProcessStatus::Killed) + .on_executor_finished(session.id, ExecutionProcessStatus::Killed, true) .await .unwrap() ); @@ -2021,6 +2022,44 @@ mod tests { ); } + #[tokio::test] + async fn finish_hook_self_guards_a_non_releasing_chained_action() { + let (db, _workspace, session) = fixture().await; + let row = store_message( + &db, + session.id, + "wait for the chained action", + QueuedMessageSource::Ui, + ) + .await; + let dispatcher_state = Arc::new(StdMutex::new(DispatcherState::default())); + let (service, _) = service_with_components( + db.clone(), + report(false, Some(false), SidEvidence::Unknown), + Arc::new(FakeTransport), + Arc::new(RecordingDispatcher { + db: db.clone(), + state: dispatcher_state.clone(), + }), + ); + + assert!( + !service + .on_executor_finished(session.id, ExecutionProcessStatus::Completed, false) + .await + .unwrap() + ); + assert!(dispatcher_state.lock().unwrap().prompts.is_empty()); + assert_eq!( + SessionQueuedMessage::find_by_id(&db.pool, row.id) + .await + .unwrap() + .unwrap() + .state, + QueuedMessageState::Queued + ); + } + #[tokio::test] async fn drain_recovers_busy_resume_pane_when_finish_hook_was_missed() { let (db, workspace, session) = fixture().await; @@ -2084,7 +2123,7 @@ mod tests { assert!( !service - .on_executor_finished(session.id, ExecutionProcessStatus::Completed) + .on_executor_finished(session.id, ExecutionProcessStatus::Completed, true) .await .unwrap() ); @@ -2124,7 +2163,7 @@ mod tests { ); let error = service - .on_executor_finished(session.id, ExecutionProcessStatus::Completed) + .on_executor_finished(session.id, ExecutionProcessStatus::Completed, true) .await .unwrap_err(); @@ -2488,7 +2527,7 @@ mod tests { .unwrap(); assert!( !service - .on_executor_finished(session.id, ExecutionProcessStatus::Completed) + .on_executor_finished(session.id, ExecutionProcessStatus::Completed, true) .await .unwrap() ); diff --git a/crates/services/src/services/container.rs b/crates/services/src/services/container.rs index 757510d5a9b..81a051ff72a 100644 --- a/crates/services/src/services/container.rs +++ b/crates/services/src/services/container.rs @@ -57,7 +57,9 @@ use utils::{ use uuid::Uuid; use worktree_manager::WorktreeError; -use crate::services::{execution_process, notification::NotificationService}; +use crate::services::{ + cli_collab::CliCollabService, execution_process, notification::NotificationService, +}; pub type ContainerRef = String; #[derive(Debug, Error)] @@ -94,6 +96,8 @@ pub trait ContainerService { fn notification_service(&self) -> &NotificationService; + fn cli_collab(&self) -> Option<&Arc>; + async fn touch(&self, workspace: &Workspace) -> Result<(), ContainerError>; fn workspace_to_current_dir(&self, workspace: &Workspace) -> PathBuf; @@ -321,6 +325,18 @@ pub trait ContainerService { } // Process marked as failed tracing::info!("Marked orphaned execution process {} as failed", process.id); + if let Some(cli_collab) = self.cli_collab() + && let Err(error) = cli_collab + .on_executor_finished(process.session_id, ExecutionProcessStatus::Failed, true) + .await + { + tracing::error!( + ?error, + session_id = %process.session_id, + execution_process_id = %process.id, + "CLI collaboration orphan completion hook failed closed" + ); + } } Ok(()) } From af39803863e6d016dba40fc05800b2ca33430601 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 00:15:54 +0000 Subject: [PATCH 26/36] refactor(collab): consolidate shared state logic --- ...2b93cea4fa86653c95a318153c84ee6eb242.json} | 4 +- crates/db/src/models/cli_native_record.rs | 85 ++---------- crates/db/src/models/cli_pane_binding.rs | 14 -- crates/db/src/models/execution_process.rs | 124 +++++++++++++++--- .../db/src/models/session_queued_message.rs | 6 - .../src/models/workspace_spawn_reservation.rs | 57 +++++++- crates/local-deployment/src/pty.rs | 61 +++------ crates/server/src/routes/sessions/mod.rs | 20 +-- crates/server/src/routes/terminal.rs | 5 +- crates/services/src/services/cli_collab.rs | 19 ++- .../services/src/services/queued_message.rs | 1 + .../model/collaborationUiState.test.ts | 8 -- .../model/collaborationUiState.ts | 67 ++++++---- .../model/hooks/useSessionQueueInteraction.ts | 7 +- .../ui/ConversationListContainer.tsx | 7 +- .../ui/SessionChatBoxContainer.tsx | 7 +- 16 files changed, 263 insertions(+), 229 deletions(-) rename crates/db/.sqlx/{query-ce4e0dac1ea70528f91d71049432c7f0bce031e5ba46a103967853295256b7a3.json => query-ce75e8250227891f8cd516d89cdb2b93cea4fa86653c95a318153c84ee6eb242.json} (53%) diff --git a/crates/db/.sqlx/query-ce4e0dac1ea70528f91d71049432c7f0bce031e5ba46a103967853295256b7a3.json b/crates/db/.sqlx/query-ce75e8250227891f8cd516d89cdb2b93cea4fa86653c95a318153c84ee6eb242.json similarity index 53% rename from crates/db/.sqlx/query-ce4e0dac1ea70528f91d71049432c7f0bce031e5ba46a103967853295256b7a3.json rename to crates/db/.sqlx/query-ce75e8250227891f8cd516d89cdb2b93cea4fa86653c95a318153c84ee6eb242.json index 2d72a6c3a45..a337f47718d 100644 --- a/crates/db/.sqlx/query-ce4e0dac1ea70528f91d71049432c7f0bce031e5ba46a103967853295256b7a3.json +++ b/crates/db/.sqlx/query-ce75e8250227891f8cd516d89cdb2b93cea4fa86653c95a318153c84ee6eb242.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "DELETE FROM workspace_spawn_reservations\n WHERE workspace_id = $1 AND expires_at <= $2", + "query": "DELETE FROM workspace_spawn_reservations\n WHERE workspace_id = $1\n AND julianday(expires_at) <= julianday($2)", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "ce4e0dac1ea70528f91d71049432c7f0bce031e5ba46a103967853295256b7a3" + "hash": "ce75e8250227891f8cd516d89cdb2b93cea4fa86653c95a318153c84ee6eb242" } diff --git a/crates/db/src/models/cli_native_record.rs b/crates/db/src/models/cli_native_record.rs index 1d95ddbfa83..70e992e0b08 100644 --- a/crates/db/src/models/cli_native_record.rs +++ b/crates/db/src/models/cli_native_record.rs @@ -237,79 +237,14 @@ impl CliNativeRecord { records: &[NewCliNativeRecord], cursor: &ImportedCursor<'_>, ) -> Result { - let mut tx = pool.begin().await?; - let next_generation = sqlx::query_scalar!( - r#"SELECT COALESCE(MAX(generation), -1) + 1 AS "generation!: i64" - FROM cli_native_files - WHERE dir_path = $1 AND file_name = $2"#, - registration.dir_path, - registration.file_name - ) - .fetch_one(&mut *tx) - .await?; - - // Capture the pre-purge watermark so replacement rows never reuse a - // sequence already observed by a connected publisher. - let session_id = sqlx::query_scalar!( - r#"SELECT session_id AS "session_id!: Uuid" - FROM claude_session_links - WHERE claude_session_id = $1"#, - registration.claude_session_id - ) - .fetch_optional(&mut *tx) - .await?; - let next_outbox_seq = if let Some(session_id) = session_id { - Some(CliIngestOutbox::next_seq_in_transaction(&mut tx, session_id).await?) - } else { - None - }; - - let file_id = Uuid::new_v4(); - sqlx::query!( - r#"INSERT INTO cli_native_files - (id, claude_session_id, dir_path, file_name, - discovered_workspace_id, dev, inode, generation, - observed_size, observed_mtime_ms) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"#, - file_id, - registration.claude_session_id, - registration.dir_path, - registration.file_name, - registration.discovered_workspace_id, - registration.dev, - registration.inode, - next_generation, - registration.observed_size, - registration.observed_mtime_ms - ) - .execute(&mut *tx) - .await?; - - sqlx::query!( - r#"DELETE FROM cli_native_files - WHERE dir_path = $1 AND file_name = $2 AND id != $3"#, - registration.dir_path, - registration.file_name, - file_id - ) - .execute(&mut *tx) - .await?; - - let imported = Self::import_batch_in_transaction( - &mut tx, - file_id, + Self::replace_generation_and_import_batch_with_context( + pool, + registration, records, cursor, - next_outbox_seq, NativeImportContext::default(), ) - .await?; - tx.commit().await?; - - let file = CliNativeFile::find_by_id(pool, file_id) - .await? - .expect("activated native generation exists"); - Ok(ReplacedGenerationImport { file, imported }) + .await } pub async fn replace_generation_and_import_batch_with_context( @@ -441,7 +376,9 @@ impl CliNativeRecord { // without a timestamp use this import's clock so they // can still reconcile without matching old turns. let reference_time = record.recorded_at.unwrap_or(imported_at); - let window_start = reference_time - chrono::Duration::minutes(15); + let window_start = reference_time + .checked_sub_signed(chrono::Duration::minutes(15)) + .unwrap_or(reference_time); sqlx::query_scalar!( r#"SELECT cat.id AS "id!: Uuid" FROM coding_agent_turns cat @@ -485,8 +422,12 @@ impl CliNativeRecord { match (record.user_prompt.as_deref(), session_id) { (Some(prompt), Some(session_id)) => { let reference_time = record.recorded_at.unwrap_or(imported_at); - let earliest_paste = reference_time - chrono::Duration::minutes(15); - let latest_paste = reference_time + chrono::Duration::seconds(5); + let earliest_paste = reference_time + .checked_sub_signed(chrono::Duration::minutes(15)) + .unwrap_or(reference_time); + let latest_paste = reference_time + .checked_add_signed(chrono::Duration::seconds(5)) + .unwrap_or(reference_time); sqlx::query_scalar!( r#"SELECT id AS "id!: Uuid" FROM session_queued_messages diff --git a/crates/db/src/models/cli_pane_binding.rs b/crates/db/src/models/cli_pane_binding.rs index c17ae31dd37..087ae849578 100644 --- a/crates/db/src/models/cli_pane_binding.rs +++ b/crates/db/src/models/cli_pane_binding.rs @@ -141,18 +141,4 @@ impl CliPaneBinding { .await?; Ok(result.rows_affected() > 0) } - - pub async fn release_workspace( - pool: &SqlitePool, - workspace_id: Uuid, - ) -> Result { - Ok(sqlx::query!( - r#"UPDATE cli_pane_bindings SET released_at = datetime('now', 'subsec') - WHERE workspace_id = $1 AND released_at IS NULL"#, - workspace_id - ) - .execute(pool) - .await? - .rows_affected()) - } } diff --git a/crates/db/src/models/execution_process.rs b/crates/db/src/models/execution_process.rs index 620a18901c2..a294c1f0f8f 100644 --- a/crates/db/src/models/execution_process.rs +++ b/crates/db/src/models/execution_process.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use chrono::{DateTime, Utc}; use executors::{ actions::{ExecutorAction, ExecutorActionType}, - profile::ExecutorProfileId, + profile::{ExecutorConfig, ExecutorProfileId}, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -594,14 +594,11 @@ impl ExecutionProcess { }) } - /// Fetch the latest CodingAgent executor profile for a session. - /// Returns None if no CodingAgent execution process exists for this session. - pub async fn latest_executor_profile_for_session( + async fn latest_coding_agent_for_session( pool: &SqlitePool, session_id: Uuid, - ) -> Result, ExecutionProcessError> { - // Find the latest CodingAgent execution process for this session - let latest_execution_process = sqlx::query_as!( + ) -> Result, sqlx::Error> { + sqlx::query_as!( ExecutionProcess, r#"SELECT ep.id as "id!: Uuid", @@ -622,9 +619,18 @@ impl ExecutionProcess { ExecutionProcessRunReason::CodingAgent ) .fetch_optional(pool) - .await?; + .await + } - let Some(latest_execution_process) = latest_execution_process else { + /// Fetch the latest CodingAgent executor configuration for a session. + /// Returns None if no CodingAgent execution process exists for this session. + pub async fn latest_executor_config_for_session( + pool: &SqlitePool, + session_id: Uuid, + ) -> Result, ExecutionProcessError> { + let Some(latest_execution_process) = + Self::latest_coding_agent_for_session(pool, session_id).await? + else { return Ok(None); }; @@ -634,20 +640,29 @@ impl ExecutionProcess { match &action.typ { ExecutorActionType::CodingAgentInitialRequest(request) => { - Ok(Some(request.executor_config.profile_id())) + Ok(Some(request.executor_config.clone())) } ExecutorActionType::CodingAgentFollowUpRequest(request) => { - Ok(Some(request.executor_config.profile_id())) - } - ExecutorActionType::ReviewRequest(request) => { - Ok(Some(request.executor_config.profile_id())) + Ok(Some(request.executor_config.clone())) } + ExecutorActionType::ReviewRequest(request) => Ok(Some(request.executor_config.clone())), _ => Err(ExecutionProcessError::ValidationError( - "Couldn't find profile from initial request".to_string(), + "Couldn't find executor configuration from coding-agent action".to_string(), )), } } + /// Fetch the latest CodingAgent executor profile for a session. + /// Returns None if no CodingAgent execution process exists for this session. + pub async fn latest_executor_profile_for_session( + pool: &SqlitePool, + session_id: Uuid, + ) -> Result, ExecutionProcessError> { + Ok(Self::latest_executor_config_for_session(pool, session_id) + .await? + .map(|config| config.profile_id())) + } + /// Fetch latest execution process info for all workspaces with the given archived status. /// Returns a map of workspace_id -> LatestProcessInfo for the most recent /// non-dropped execution process (excluding dev servers). @@ -721,3 +736,82 @@ impl ExecutionProcess { Ok(rows.into_iter().collect()) } } + +#[cfg(test)] +mod tests { + use executors::{ + actions::{ExecutorAction, ExecutorActionType, review::ReviewRequest}, + executors::BaseCodingAgent, + profile::ExecutorConfig, + }; + use sqlx::sqlite::SqlitePoolOptions; + + use super::*; + use crate::models::{ + session::{CreateSession, Session}, + workspace::{CreateWorkspace, Workspace}, + }; + + #[tokio::test] + async fn latest_executor_config_includes_review_only_sessions() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + crate::run_migrations_for_tests(&pool).await.unwrap(); + let workspace_id = Uuid::new_v4(); + Workspace::create( + &pool, + &CreateWorkspace { + branch: "main".to_string(), + name: Some("review config fixture".to_string()), + }, + workspace_id, + ) + .await + .unwrap(); + let session_id = Uuid::new_v4(); + Session::create( + &pool, + &CreateSession { + executor: Some(BaseCodingAgent::ClaudeCode.to_string()), + name: None, + }, + session_id, + workspace_id, + ) + .await + .unwrap(); + let mut expected = ExecutorConfig::new(BaseCodingAgent::Codex); + expected.model_id = Some("review-model".to_string()); + ExecutionProcess::create( + &pool, + &CreateExecutionProcess { + session_id, + executor_action: ExecutorAction::new( + ExecutorActionType::ReviewRequest(ReviewRequest { + executor_config: expected.clone(), + context: None, + prompt: "review".to_string(), + session_id: None, + working_dir: None, + }), + None, + ), + run_reason: ExecutionProcessRunReason::CodingAgent, + }, + Uuid::new_v4(), + &[], + ) + .await + .unwrap(); + + assert_eq!( + ExecutionProcess::latest_executor_config_for_session(&pool, session_id) + .await + .unwrap(), + Some(expected) + ); + } +} diff --git a/crates/db/src/models/session_queued_message.rs b/crates/db/src/models/session_queued_message.rs index 43ae1e87b1a..63448c1cf0b 100644 --- a/crates/db/src/models/session_queued_message.rs +++ b/crates/db/src/models/session_queued_message.rs @@ -29,12 +29,6 @@ pub enum QueuedMessageState { Cancelled, } -impl QueuedMessageState { - pub fn is_active(self) -> bool { - matches!(self, Self::Queued | Self::Pasting | Self::Pasted) - } -} - #[derive(Debug, Clone, FromRow, Serialize, Deserialize, TS)] pub struct SessionQueuedMessage { pub id: Uuid, diff --git a/crates/db/src/models/workspace_spawn_reservation.rs b/crates/db/src/models/workspace_spawn_reservation.rs index 1eddceef905..62abfca8ac9 100644 --- a/crates/db/src/models/workspace_spawn_reservation.rs +++ b/crates/db/src/models/workspace_spawn_reservation.rs @@ -42,7 +42,8 @@ impl WorkspaceSpawnReservation { let mut tx = pool.begin().await?; sqlx::query!( r#"DELETE FROM workspace_spawn_reservations - WHERE workspace_id = $1 AND expires_at <= $2"#, + WHERE workspace_id = $1 + AND julianday(expires_at) <= julianday($2)"#, workspace_id, now ) @@ -207,4 +208,58 @@ mod tests { .unwrap() ); } + + #[tokio::test] + async fn reservation_expiry_compares_mixed_timestamp_formats_chronologically() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + crate::run_migrations_for_tests(&pool).await.unwrap(); + let workspace_id = Uuid::new_v4(); + Workspace::create( + &pool, + &CreateWorkspace { + branch: "main".to_string(), + name: Some("timestamp fixture".to_string()), + }, + workspace_id, + ) + .await + .unwrap(); + let now = "2026-07-21T11:00:00Z".parse::>().unwrap(); + WorkspaceSpawnReservation::acquire_at( + &pool, + workspace_id, + SpawnReservationHolder::Executor, + now, + Duration::hours(1), + ) + .await + .unwrap() + .unwrap(); + sqlx::query( + "UPDATE workspace_spawn_reservations SET expires_at = \ + '2026-07-21 12:00:00' WHERE workspace_id = ?", + ) + .bind(workspace_id) + .execute(&pool) + .await + .unwrap(); + + assert!( + WorkspaceSpawnReservation::acquire_at( + &pool, + workspace_id, + SpawnReservationHolder::Cli, + now, + Duration::minutes(15), + ) + .await + .unwrap() + .is_none(), + "a future SQL datetime must not be deleted by textual ordering" + ); + } } diff --git a/crates/local-deployment/src/pty.rs b/crates/local-deployment/src/pty.rs index d94e68e20ae..9b039314117 100644 --- a/crates/local-deployment/src/pty.rs +++ b/crates/local-deployment/src/pty.rs @@ -371,13 +371,7 @@ pub fn cli_resume_ready_file_path(workspace_id: Uuid) -> PathBuf { .join(format!("{}.ready", workspace_id.simple())) } -fn write_cli_resume_ready_file_at(path: &Path, sid: &str, now: SystemTime) -> std::io::Result<()> { - let _ = Uuid::parse_str(sid) - .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; - let timestamp = now - .duration_since(UNIX_EPOCH) - .map_err(std::io::Error::other)? - .as_secs(); +fn write_private_file(path: &Path, content: &[u8]) -> std::io::Result<()> { if let Some(dir) = path.parent() { #[cfg(unix)] { @@ -399,14 +393,26 @@ fn write_cli_resume_ready_file_at(path: &Path, sid: &str, now: SystemTime) -> st .truncate(true) .mode(0o600) .open(path)?; + // `.mode()` only applies when the file is created. Force the mode on + // every write so a pre-existing, same-user file cannot stay broader. file.set_permissions(std::fs::Permissions::from_mode(0o600))?; - writeln!(file, "{timestamp} {sid}")?; + file.write_all(content)?; } #[cfg(not(unix))] - std::fs::write(path, format!("{timestamp} {sid}\n"))?; + std::fs::write(path, content)?; Ok(()) } +fn write_cli_resume_ready_file_at(path: &Path, sid: &str, now: SystemTime) -> std::io::Result<()> { + let _ = Uuid::parse_str(sid) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidInput, error))?; + let timestamp = now + .duration_since(UNIX_EPOCH) + .map_err(std::io::Error::other)? + .as_secs(); + write_private_file(path, format!("{timestamp} {sid}\n").as_bytes()) +} + pub fn write_cli_resume_ready_file(workspace_id: Uuid, sid: &str) -> std::io::Result<()> { write_cli_resume_ready_file_at( &cli_resume_ready_file_path(workspace_id), @@ -425,42 +431,7 @@ pub fn remove_cli_resume_ready_file(workspace_id: Uuid) { /// [`kill_cli_tmux_session`] clean up the never-consumed case. fn write_cli_prompt_file(workspace_id: Uuid, content: &str) -> std::io::Result { let path = cli_prompt_file_path(workspace_id); - if let Some(dir) = path.parent() { - // Owner-only dir: the files inside are already 0600, but a 0700 dir - // also keeps prompt-file names (workspace ids + staging times) from - // being enumerable by other local users. - #[cfg(unix)] - { - use std::os::unix::fs::DirBuilderExt; - std::fs::DirBuilder::new() - .recursive(true) - .mode(0o700) - .create(dir)?; - } - #[cfg(not(unix))] - std::fs::create_dir_all(dir)?; - } - #[cfg(unix)] - { - use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - let mut f = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&path)?; - // `.mode()` only applies when the file is CREATED; if a looser-perm file - // pre-existed at this path (stale from an older build, or same-user - // tampering) `create(true)` reuses it without re-chmod'ing. Force 0600 - // so the prompt is never readable at wider perms. (The 0700 dir already - // blocks other users; this is defense in depth.) - f.set_permissions(std::fs::Permissions::from_mode(0o600))?; - f.write_all(content.as_bytes())?; - } - #[cfg(not(unix))] - { - std::fs::write(&path, content.as_bytes())?; - } + write_private_file(&path, content.as_bytes())?; Ok(path) } diff --git a/crates/server/src/routes/sessions/mod.rs b/crates/server/src/routes/sessions/mod.rs index 97d144ac46d..ff805e1008c 100644 --- a/crates/server/src/routes/sessions/mod.rs +++ b/crates/server/src/routes/sessions/mod.rs @@ -24,7 +24,6 @@ use db::models::{ }; use deployment::Deployment; use executors::{ - actions::ExecutorActionType, executors::{BaseCodingAgent, claude::native::adapt_native_claude_line}, profile::ExecutorConfig, }; @@ -266,23 +265,10 @@ async fn latest_executor_config( pool: &sqlx::SqlitePool, session: &Session, ) -> Result { - for process in ExecutionProcess::find_by_session_id(pool, session.id, false) - .await? - .iter() - .rev() + if let Some(config) = + ExecutionProcess::latest_executor_config_for_session(pool, session.id).await? { - let Ok(action) = process.executor_action() else { - continue; - }; - match action.typ() { - ExecutorActionType::CodingAgentInitialRequest(request) => { - return Ok(request.executor_config.clone()); - } - ExecutorActionType::CodingAgentFollowUpRequest(request) => { - return Ok(request.executor_config.clone()); - } - _ => {} - } + return Ok(config); } let executor = session .executor diff --git a/crates/server/src/routes/terminal.rs b/crates/server/src/routes/terminal.rs index 62e858e1e8b..30506fb85c2 100644 --- a/crates/server/src/routes/terminal.rs +++ b/crates/server/src/routes/terminal.rs @@ -288,12 +288,13 @@ async fn terminal_ws( let resume_session_id = (!executor_active) .then(|| known_claude_session_id.clone()) .flatten(); - let cli_session_exists = if cli_tmux_available() { + let tmux_available = cli_tmux_available(); + let cli_session_exists = if tmux_available { cli_tmux_session_exists_checked(query.workspace_id).await? } else { false }; - if cli_tmux_available() && !cli_session_exists { + if tmux_available && !cli_session_exists { let launch_session = session.as_ref().ok_or_else(|| { ApiError::BadRequest( "CLI mode requires a BetterCoding session for this workspace".to_string(), diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index 244a316f54c..4337e568d86 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -210,10 +210,7 @@ impl CliCollabService { lock } - pub fn wake_drain(&self) { - self.notify.notify_one(); - } - + #[cfg(test)] pub async fn derive_lease(&self, session: &Session) -> WriterLease { let lock = self.session_lock(session.id).await; let _guard = lock.lock().await; @@ -391,13 +388,12 @@ impl CliCollabService { replace, ) .await?; - if let PreparedSlot::Conflict(status) = existing { - return Ok(DispatchOutcome::Conflict { status }); - } let existing = match existing { PreparedSlot::None => None, PreparedSlot::Stored(row) => Some(row), - PreparedSlot::Conflict(_) => unreachable!(), + PreparedSlot::Conflict(status) => { + return Ok(DispatchOutcome::Conflict { status }); + } }; let lease = self.derive_lease_locked(session).await; @@ -510,7 +506,7 @@ impl CliCollabService { } } - pub async fn status(&self, session_id: Uuid) -> Result { + async fn status(&self, session_id: Uuid) -> Result { match SessionQueuedMessage::find_active(&self.db.pool, session_id).await? { Some(row) => Self::status_from_row(row), None => Ok(QueueStatus::Empty), @@ -594,8 +590,9 @@ impl CliCollabService { ) .await? { - StoreQueuedMessageResult::Stored(row) => Ok(row), - StoreQueuedMessageResult::Conflict(row) => Ok(row), + StoreQueuedMessageResult::Stored(row) | StoreQueuedMessageResult::Conflict(row) => { + Ok(row) + } } } diff --git a/crates/services/src/services/queued_message.rs b/crates/services/src/services/queued_message.rs index 57ce3a78a3a..4300285a1ca 100644 --- a/crates/services/src/services/queued_message.rs +++ b/crates/services/src/services/queued_message.rs @@ -95,6 +95,7 @@ impl QueueStatus { } } + #[cfg(test)] pub fn message(&self) -> Option<&QueuedMessage> { match self { Self::Empty => None, diff --git a/packages/web-core/src/features/workspace-chat/model/collaborationUiState.test.ts b/packages/web-core/src/features/workspace-chat/model/collaborationUiState.test.ts index 3a9ae1053a8..1a3af9224b7 100644 --- a/packages/web-core/src/features/workspace-chat/model/collaborationUiState.test.ts +++ b/packages/web-core/src/features/workspace-chat/model/collaborationUiState.test.ts @@ -52,8 +52,6 @@ describe('mapDispatchOutcomeToUiState', () => { kind: 'started', notice: 'none', queueStatus: { status: 'empty' }, - clearComposer: true, - confirmReplacement: false, }); }); @@ -67,8 +65,6 @@ describe('mapDispatchOutcomeToUiState', () => { kind: 'queued', notice: 'held', queueStatus: queued, - clearComposer: true, - confirmReplacement: false, }); expect( mapDispatchOutcomeToUiState({ @@ -79,8 +75,6 @@ describe('mapDispatchOutcomeToUiState', () => { kind: 'routed-to-cli', notice: 'routed-to-cli', queueStatus: pasted, - clearComposer: true, - confirmReplacement: false, }); expect( mapDispatchOutcomeToUiState({ outcome: 'conflict', status: queued }) @@ -88,8 +82,6 @@ describe('mapDispatchOutcomeToUiState', () => { kind: 'conflict', notice: 'none', queueStatus: queued, - clearComposer: false, - confirmReplacement: true, }); }); diff --git a/packages/web-core/src/features/workspace-chat/model/collaborationUiState.ts b/packages/web-core/src/features/workspace-chat/model/collaborationUiState.ts index a341dc7c496..b72882c85d0 100644 --- a/packages/web-core/src/features/workspace-chat/model/collaborationUiState.ts +++ b/packages/web-core/src/features/workspace-chat/model/collaborationUiState.ts @@ -16,8 +16,6 @@ export interface DispatchUiState { kind: DispatchUiKind; notice: DispatchNoticeKind; queueStatus: QueueStatus; - clearComposer: boolean; - confirmReplacement: boolean; } export type QueueChipKind = 'queued' | 'pasting' | 'pasted' | 'failed'; @@ -29,10 +27,23 @@ export interface QueueChipState { canSendAgain: boolean; } +export function assertNever(value: never): never { + throw new Error(`Unhandled collaboration state: ${String(value)}`); +} + export function getActiveQueuedMessage( status: QueueStatus ): QueuedMessage | null { - return status.status === 'empty' ? null : status.message; + switch (status.status) { + case 'empty': + return null; + case 'queued': + case 'pasting': + case 'pasted': + return status.message; + default: + return assertNever(status); + } } /** @@ -49,8 +60,6 @@ export function mapDispatchOutcomeToUiState( kind: 'started', notice: 'none', queueStatus: { status: 'empty' }, - clearComposer: true, - confirmReplacement: false, }; case 'queued': { const message = getActiveQueuedMessage(outcome.status); @@ -58,8 +67,6 @@ export function mapDispatchOutcomeToUiState( kind: 'queued', notice: message?.failure_reason ? 'delivery-failed' : 'held', queueStatus: outcome.status, - clearComposer: true, - confirmReplacement: false, }; } case 'routed_to_cli': @@ -67,17 +74,15 @@ export function mapDispatchOutcomeToUiState( kind: 'routed-to-cli', notice: 'routed-to-cli', queueStatus: outcome.delivery, - clearComposer: true, - confirmReplacement: false, }; case 'conflict': return { kind: 'conflict', notice: 'none', queueStatus: outcome.status, - clearComposer: false, - confirmReplacement: true, }; + default: + return assertNever(outcome); } } @@ -85,22 +90,30 @@ export function mapDispatchOutcomeToUiState( export function deriveQueueChipState( status: QueueStatus ): QueueChipState | null { - if (status.status === 'empty') return null; + switch (status.status) { + case 'empty': + return null; + case 'queued': + case 'pasting': + case 'pasted': { + const failureReason = status.message.failure_reason; + if (failureReason) { + return { + kind: 'failed', + message: status.message, + failureReason, + canSendAgain: status.status === 'queued', + }; + } - const failureReason = status.message.failure_reason; - if (failureReason) { - return { - kind: 'failed', - message: status.message, - failureReason, - canSendAgain: status.status === 'queued', - }; + return { + kind: status.status, + message: status.message, + failureReason: null, + canSendAgain: false, + }; + } + default: + return assertNever(status); } - - return { - kind: status.status, - message: status.message, - failureReason: null, - canSendAgain: false, - }; } diff --git a/packages/web-core/src/features/workspace-chat/model/hooks/useSessionQueueInteraction.ts b/packages/web-core/src/features/workspace-chat/model/hooks/useSessionQueueInteraction.ts index 7ccd3adda8f..aae8dd47b24 100644 --- a/packages/web-core/src/features/workspace-chat/model/hooks/useSessionQueueInteraction.ts +++ b/packages/web-core/src/features/workspace-chat/model/hooks/useSessionQueueInteraction.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ApiError, queueApi } from '@/shared/lib/api'; -import type { ExecutorConfig, QueuedMessage, QueueStatus } from 'shared/types'; +import type { ExecutorConfig, QueueStatus } from 'shared/types'; interface UseSessionQueueInteractionOptions { /** Session ID for queue operations */ @@ -17,8 +17,6 @@ interface UseSessionQueueInteractionResult { queueStatus: QueueStatus; /** Whether the active slot can still be edited or cancelled. */ isQueued: boolean; - /** The active queue record in any delivery phase. */ - activeMessage: QueuedMessage | null; /** The editable queued message content, if any. */ queuedMessage: string | null; /** The executor config from the editable queued message, if any. */ @@ -77,8 +75,6 @@ export function useSessionQueueInteraction({ }, }); - const activeMessage = - queueStatus.status === 'empty' ? null : queueStatus.message; const editableMessage = queueStatus.status === 'queued' ? queueStatus.message : null; const isQueued = editableMessage !== null; @@ -163,7 +159,6 @@ export function useSessionQueueInteraction({ return { queueStatus, isQueued, - activeMessage, queuedMessage, queuedConfig, isQueueLoading: queueMutation.isPending || cancelMutation.isPending, diff --git a/packages/web-core/src/features/workspace-chat/ui/ConversationListContainer.tsx b/packages/web-core/src/features/workspace-chat/ui/ConversationListContainer.tsx index 42ad6d54a12..186d06043a6 100644 --- a/packages/web-core/src/features/workspace-chat/ui/ConversationListContainer.tsx +++ b/packages/web-core/src/features/workspace-chat/ui/ConversationListContainer.tsx @@ -61,7 +61,10 @@ import { Badge } from '@vibe/ui/components/Badge'; import { UnassignedCliSessions } from './UnassignedCliSessions'; import { shouldShowForeignWriterBanner } from '../model/foreignWriterBanner'; import { sessionsApi } from '@/shared/lib/api'; -import { mapDispatchOutcomeToUiState } from '../model/collaborationUiState'; +import { + assertNever, + mapDispatchOutcomeToUiState, +} from '../model/collaborationUiState'; import { sessionQueueKeys } from '../model/hooks/useSessionQueueInteraction'; import { useTransientToast } from '@/shared/hooks/useTransientToast'; import { Toast } from '@vibe/ui/components/Toast'; @@ -611,6 +614,8 @@ export const ConversationList = forwardRef< 'warning' ); break; + default: + assertNever(uiState.kind); } } catch { showRecoveryToast(t('conversation.fork.recoveryErrorToast'), 'error'); diff --git a/packages/web-core/src/features/workspace-chat/ui/SessionChatBoxContainer.tsx b/packages/web-core/src/features/workspace-chat/ui/SessionChatBoxContainer.tsx index 6e264ad0ef8..266017baf9b 100644 --- a/packages/web-core/src/features/workspace-chat/ui/SessionChatBoxContainer.tsx +++ b/packages/web-core/src/features/workspace-chat/ui/SessionChatBoxContainer.tsx @@ -72,6 +72,7 @@ import { RenameSessionDialog } from '@vibe/ui/components/RenameSessionDialog'; import type { TurnNavigationItem } from '@vibe/ui/components/TurnNavigationPopup'; import { ConfirmDialog } from '@vibe/ui/components/ConfirmDialog'; import { + assertNever, deriveQueueChipState, getActiveQueuedMessage, mapDispatchOutcomeToUiState, @@ -531,6 +532,8 @@ export function SessionChatBoxContainer(props: SessionChatBoxContainerProps) { break; case 'none': break; + default: + assertNever(notice); } }, [showToast, t] @@ -599,7 +602,7 @@ export function SessionChatBoxContainer(props: SessionChatBoxContainerProps) { ); } - if (uiState.confirmReplacement) { + if (uiState.kind === 'conflict') { if (replace) { showReplacementConflict(uiState.queueStatus); return false; @@ -609,7 +612,7 @@ export function SessionChatBoxContainer(props: SessionChatBoxContainerProps) { } showDispatchNotice(uiState.notice); - return uiState.clearComposer; + return true; }; const accepted = await attemptDispatch(false); From 600e9ca1737b6c0b2ceae5ae9da01f09b924cf04 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 00:20:05 +0000 Subject: [PATCH 27/36] perf(collab): avoid redundant writer probes --- crates/local-deployment/src/writer_probe.rs | 41 ++++---- .../src/services/claude_transcript_ingest.rs | 18 +++- .../claude_transcript_ingest/tests.rs | 60 ++++++++++-- crates/services/src/services/cli_collab.rs | 98 ++++++++++++------- 4 files changed, 155 insertions(+), 62 deletions(-) diff --git a/crates/local-deployment/src/writer_probe.rs b/crates/local-deployment/src/writer_probe.rs index 0b0e2559552..e11adce110d 100644 --- a/crates/local-deployment/src/writer_probe.rs +++ b/crates/local-deployment/src/writer_probe.rs @@ -141,15 +141,9 @@ impl CliWriterProbe for LocalCliWriterProbe { workspace_id: Uuid, effective_dir: &Path, _expected_sid: Option<&str>, + binding: Option<&CliPaneBinding>, + check_cwd_uniqueness: bool, ) -> ProbeReport { - let binding = - match CliPaneBinding::find_active_for_workspace(&self.db.pool, workspace_id).await { - Ok(binding) => binding, - Err(error) => { - tracing::warn!(?error, %workspace_id, "CLI pane binding probe failed"); - return ProbeReport::failed(); - } - }; let exists = match cli_tmux_session_exists_checked(workspace_id).await { Ok(exists) => exists, Err(error) => { @@ -169,7 +163,7 @@ impl CliWriterProbe for LocalCliWriterProbe { agent_running: Some(false), sid_evidence: SidEvidence::Unknown, probe_failed: false, - only_active_claude_in_cwd: Some(false), + only_active_claude_in_cwd: check_cwd_uniqueness.then_some(false), }; } @@ -178,15 +172,26 @@ impl CliWriterProbe for LocalCliWriterProbe { None => return ProbeReport::failed(), }; let agent_running = !processes.is_empty(); - let pane_pids = processes.iter().map(|process| process.pid).collect(); - live_process_report( - &processes, - if agent_running { - only_active_claude_in_cwd(effective_dir, &pane_pids) - } else { - Some(false) - }, - ) + let cwd_uniqueness = if !check_cwd_uniqueness { + None + } else if !agent_running { + Some(false) + } else { + let effective_dir = effective_dir.to_path_buf(); + let pane_pids = processes.iter().map(|process| process.pid).collect(); + match tokio::task::spawn_blocking(move || { + only_active_claude_in_cwd(&effective_dir, &pane_pids) + }) + .await + { + Ok(result) => result, + Err(error) => { + tracing::warn!(?error, %workspace_id, "CLI cwd uniqueness probe failed"); + None + } + } + }; + live_process_report(&processes, cwd_uniqueness) } } diff --git a/crates/services/src/services/claude_transcript_ingest.rs b/crates/services/src/services/claude_transcript_ingest.rs index dfc78ef969a..50156059dfa 100644 --- a/crates/services/src/services/claude_transcript_ingest.rs +++ b/crates/services/src/services/claude_transcript_ingest.rs @@ -150,6 +150,8 @@ impl CliWriterProbe for TestNoPaneWriterProbe { _workspace_id: Uuid, _effective_dir: &Path, _expected_sid: Option<&str>, + _binding: Option<&CliPaneBinding>, + _check_cwd_uniqueness: bool, ) -> crate::services::cli_collab::ProbeReport { crate::services::cli_collab::ProbeReport { pane_session_exists: false, @@ -756,7 +758,13 @@ impl ClaudeTranscriptIngest { let import_context = if link.is_some() { let report = self .writer_probe - .probe(context.workspace_id, &context.cwd, Some(claude_session_id)) + .probe( + context.workspace_id, + &context.cwd, + Some(claude_session_id), + None, + false, + ) .await; NativeImportContext { app_pane_absent: !(report.probe_failed @@ -944,7 +952,13 @@ impl ClaudeTranscriptIngest { } let report = self .writer_probe - .probe(context.workspace_id, &context.cwd, None) + .probe( + context.workspace_id, + &context.cwd, + None, + Some(&binding), + true, + ) .await; if report.probe_failed || !report.pane_session_exists diff --git a/crates/services/src/services/claude_transcript_ingest/tests.rs b/crates/services/src/services/claude_transcript_ingest/tests.rs index 2b393aa2ae9..6675017f03e 100644 --- a/crates/services/src/services/claude_transcript_ingest/tests.rs +++ b/crates/services/src/services/claude_transcript_ingest/tests.rs @@ -1,4 +1,10 @@ -use std::{collections::HashSet, fs, path::Path, sync::Arc, time::Duration}; +use std::{ + collections::HashSet, + fs, + path::Path, + sync::{Arc, Mutex}, + time::Duration, +}; use async_trait::async_trait; use chrono::{Duration as ChronoDuration, SecondsFormat, Utc}; @@ -51,11 +57,37 @@ impl CliWriterProbe for StaticWriterProbe { _workspace_id: Uuid, _effective_dir: &Path, _expected_sid: Option<&str>, + _binding: Option<&CliPaneBinding>, + _check_cwd_uniqueness: bool, ) -> ProbeReport { self.0.clone() } } +#[derive(Clone)] +struct RecordingWriterProbe { + report: ProbeReport, + uniqueness_checks: Arc>>, +} + +#[async_trait] +impl CliWriterProbe for RecordingWriterProbe { + async fn probe( + &self, + _workspace_id: Uuid, + _effective_dir: &Path, + _expected_sid: Option<&str>, + _binding: Option<&CliPaneBinding>, + check_cwd_uniqueness: bool, + ) -> ProbeReport { + self.uniqueness_checks + .lock() + .unwrap() + .push(check_cwd_uniqueness); + self.report.clone() + } +} + fn effective_cwd(workspace: &Workspace, session: &Session) -> Option { workspace .container_ref @@ -357,7 +389,7 @@ async fn import_foreign_writer_case(report: ProbeReport, executor_running: bool) async fn import_cli_fresh_case( release_binding: bool, report: ProbeReport, -) -> (Option, CliPaneBinding, u64) { +) -> (Option, CliPaneBinding, u64, Vec) { let temp = TempDir::new().unwrap(); let workspace_root = temp.path().join("worktree"); let projects_dir = temp.path().join("projects"); @@ -391,10 +423,14 @@ async fn import_cli_fresh_case( ) .unwrap(); + let uniqueness_checks = Arc::new(Mutex::new(Vec::new())); let service = Arc::new(ClaudeTranscriptIngest::new_with_probe( db.clone(), projects_dir, - Arc::new(StaticWriterProbe(report)), + Arc::new(RecordingWriterProbe { + report, + uniqueness_checks: uniqueness_checks.clone(), + }), )); service .reconcile_registry(false, CancellationToken::new()) @@ -412,7 +448,8 @@ async fn import_cli_fresh_case( .unwrap() .health .quarantined_files; - (link, binding, quarantined_files) + let uniqueness_checks = uniqueness_checks.lock().unwrap().clone(); + (link, binding, quarantined_files, uniqueness_checks) } #[tokio::test] @@ -447,7 +484,7 @@ async fn foreign_writer_classifier_fails_safe_for_executor_pane_and_probe_failur #[tokio::test] async fn cli_fresh_file_auto_binds_to_the_only_live_unreleased_pane() { - let (link, binding, quarantined_files) = import_cli_fresh_case( + let (link, binding, quarantined_files, _) = import_cli_fresh_case( false, writer_report(true, Some(true), SidEvidence::NoResumeArg, Some(true)), ) @@ -463,6 +500,17 @@ async fn cli_fresh_file_auto_binds_to_the_only_live_unreleased_pane() { assert_eq!(quarantined_files, 0); } +#[tokio::test] +async fn cwd_uniqueness_is_requested_only_for_cli_fresh_auto_binding() { + let (_, _, _, uniqueness_checks) = import_cli_fresh_case( + false, + writer_report(true, Some(true), SidEvidence::NoResumeArg, Some(true)), + ) + .await; + + assert_eq!(uniqueness_checks, vec![true, false]); +} + #[tokio::test] async fn cli_fresh_file_quarantines_for_released_dead_or_nonexclusive_panes() { let healthy_report = || writer_report(true, Some(true), SidEvidence::NoResumeArg, Some(true)); @@ -479,7 +527,7 @@ async fn cli_fresh_file_quarantines_for_released_dead_or_nonexclusive_panes() { ]; for (release_binding, report) in cases { - let (link, binding, quarantined_files) = + let (link, binding, quarantined_files, _) = import_cli_fresh_case(release_binding, report).await; assert!(link.is_none()); assert!(binding.claude_session_id.is_none()); diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index 4337e568d86..d12daaefc08 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -84,6 +84,8 @@ pub trait CliWriterProbe: Send + Sync { workspace_id: Uuid, effective_dir: &Path, expected_sid: Option<&str>, + binding: Option<&CliPaneBinding>, + check_cwd_uniqueness: bool, ) -> ProbeReport; } @@ -269,6 +271,8 @@ impl CliCollabService { session.workspace_id, &effective_dir, expected_sid.as_deref(), + binding.as_ref(), + false, ) .await; if report.probe_failed { @@ -1137,6 +1141,8 @@ impl CliCollabService { session.workspace_id, &effective_dir, row.claude_session_id.as_deref(), + Some(&binding), + false, ) .await; if report.probe_failed || report.agent_running.is_none() { @@ -1151,7 +1157,7 @@ impl CliCollabService { Ok(false) } - async fn reconcile_delivery_state(&self) { + async fn reconcile_delivery_state(&self, pasted_rows: Vec) { let reconciled = match SessionQueuedMessage::reconcile( &self.db.pool, Utc::now(), @@ -1167,18 +1173,8 @@ impl CliCollabService { return; } }; - let rows = match SessionQueuedMessage::list_active(&self.db.pool).await { - Ok(rows) => rows, - Err(error) => { - tracing::warn!(?error, "failed to scan pasted CLI delivery evidence"); - return; - } - }; let mut requeued_lost_evidence = 0_u64; - for row in rows - .into_iter() - .filter(|row| row.state == QueuedMessageState::Pasted) - { + for row in pasted_rows { let queue_id = row.id; let session_id = row.session_id; match self.reconcile_pasted_delivery(row).await { @@ -1207,14 +1203,18 @@ impl CliCollabService { } } - async fn reconcile_waiting_panes(&self) { - let bindings = match CliPaneBinding::list_active(&self.db.pool).await { - Ok(bindings) => bindings, - Err(error) => { - tracing::warn!(?error, "failed to scan active CLI pane bindings"); - return; - } - }; + #[cfg(test)] + async fn reconcile_delivery_state_from_db(&self) { + let pasted_rows = SessionQueuedMessage::list_active(&self.db.pool) + .await + .unwrap() + .into_iter() + .filter(|row| row.state == QueuedMessageState::Pasted) + .collect(); + self.reconcile_delivery_state(pasted_rows).await; + } + + async fn reconcile_waiting_panes(&self, bindings: Vec) { let active_ids: HashSet<_> = bindings.iter().map(|binding| binding.id).collect(); self.resume_signaled_bindings .lock() @@ -1296,23 +1296,47 @@ impl CliCollabService { } async fn drain_all(&self) { - self.reconcile_waiting_panes().await; - self.reconcile_delivery_state().await; - if self.routing_disabled { - return; - } - let rows = match SessionQueuedMessage::list_active(&self.db.pool).await { + let bindings = match CliPaneBinding::list_active(&self.db.pool).await { + Ok(bindings) => bindings, + Err(error) => { + tracing::warn!(?error, "failed to scan active CLI pane bindings"); + return; + } + }; + let active_rows = match SessionQueuedMessage::list_active(&self.db.pool).await { Ok(rows) => rows, Err(error) => { tracing::warn!(?error, "durable CLI queue scan failed closed"); return; } }; - for session_id in rows - .into_iter() - .filter(|row| row.state == QueuedMessageState::Queued) - .map(|row| row.session_id) - { + if bindings.is_empty() && active_rows.is_empty() { + self.resume_signaled_bindings.lock().await.clear(); + return; + } + + let mut pasted_rows = Vec::new(); + let mut queued_session_ids = Vec::new(); + for row in active_rows { + match row.state { + QueuedMessageState::Pasted => pasted_rows.push(row), + QueuedMessageState::Queued => queued_session_ids.push(row.session_id), + QueuedMessageState::Pasting => {} + QueuedMessageState::Imported + | QueuedMessageState::Failed + | QueuedMessageState::Consumed + | QueuedMessageState::Cancelled => { + debug_assert!(false, "active queue scan returned a terminal row"); + } + } + } + + self.reconcile_waiting_panes(bindings).await; + self.reconcile_delivery_state(pasted_rows).await; + if self.routing_disabled { + return; + } + for session_id in queued_session_ids { if let Err(error) = self.drain_session(session_id).await { tracing::warn!(?error, %session_id, "durable CLI queue drain failed"); } @@ -1320,7 +1344,7 @@ impl CliCollabService { } async fn run_drain(self: Arc) { - self.reconcile_delivery_state().await; + self.drain_all().await; let mut interval = tokio::time::interval(DRAIN_INTERVAL); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { @@ -1413,6 +1437,8 @@ mod tests { _workspace_id: Uuid, _effective_dir: &Path, _expected_sid: Option<&str>, + _binding: Option<&CliPaneBinding>, + _check_cwd_uniqueness: bool, ) -> ProbeReport { self.0.lock().unwrap().clone() } @@ -2668,7 +2694,7 @@ mod tests { Arc::new(NeverDispatcher), ); - service.reconcile_delivery_state().await; + service.reconcile_delivery_state_from_db().await; let row = SessionQueuedMessage::find_by_id(&db.pool, row.id) .await @@ -2707,7 +2733,7 @@ mod tests { .pasted_at; let (service, _) = service(db.clone(), report(false, Some(false), SidEvidence::Unknown)); - service.reconcile_delivery_state().await; + service.reconcile_delivery_state_from_db().await; let row = SessionQueuedMessage::find_by_id(&db.pool, row.id) .await @@ -2782,7 +2808,7 @@ mod tests { Arc::new(NeverDispatcher), ); - service.reconcile_delivery_state().await; + service.reconcile_delivery_state_from_db().await; let row = SessionQueuedMessage::find_by_id(&db.pool, row.id) .await @@ -2955,7 +2981,7 @@ mod tests { transport, Arc::new(NeverDispatcher), ); - service.reconcile_delivery_state().await; + service.reconcile_delivery_state_from_db().await; let interrupted = SessionQueuedMessage::find_by_id(&db.pool, interrupted.id) .await From 2273ea37e7ee8221c2583c2c2f12214e5b06b6b2 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 00:22:48 +0000 Subject: [PATCH 28/36] style(collab): satisfy deny-warnings lint --- crates/services/src/services/cli_collab.rs | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index d12daaefc08..d03981dbabf 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -742,16 +742,15 @@ impl CliCollabService { match dispatched { Ok(execution_process) => { - if let Some(row) = claimed { - if !SessionQueuedMessage::mark_consumed( + if let Some(row) = claimed + && !SessionQueuedMessage::mark_consumed( &self.db.pool, row.id, &self.executor_claim_owner, ) .await? - { - return Err(CliCollabError::ExecutorClaimLost(row.id)); - } + { + return Err(CliCollabError::ExecutorClaimLost(row.id)); } Ok(DispatchOutcome::Started { execution_process }) } From 2267390e12b2b42d32e070ef04b0c69d99d4ad5c Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 00:52:55 +0000 Subject: [PATCH 29/36] fix(collab): protect in-flight paste delivery --- ...6b1821c406b8063d2137314d9920914bb2a2e.json | 12 - ...8a504daae2175653d0baa8194703939f05a6b.json | 12 + ...54ca46e6eeec49d58ac95d558f5aa46ac12f9.json | 12 + ...ae1846543d178054863b3720dbd11fb8b8ba2.json | 12 - ...83df59c614536ff62ca8fe2b8fa4e0f49b4bb.json | 12 + ...294ecd56b982458ab7f1f5ed493a941975ab8.json | 12 + ...e0f5a0f118b0fe5919979c6cdb85589b4d302.json | 12 - ...8adada965748dad9aa0ed6c08a812827c273b.json | 12 - ...0721102000_add_queue_paste_claim_owner.sql | 5 + .../db/src/models/session_queued_message.rs | 196 ++++++++++++-- .../claude_transcript_ingest/tests.rs | 4 +- crates/services/src/services/cli_collab.rs | 246 ++++++++++++++++-- 12 files changed, 459 insertions(+), 88 deletions(-) delete mode 100644 crates/db/.sqlx/query-23088d611e12e7bf78b08417a886b1821c406b8063d2137314d9920914bb2a2e.json create mode 100644 crates/db/.sqlx/query-356d3e35103367646b7b0d7a2428a504daae2175653d0baa8194703939f05a6b.json create mode 100644 crates/db/.sqlx/query-3e632219d6e2d07d1dbfe0986b654ca46e6eeec49d58ac95d558f5aa46ac12f9.json delete mode 100644 crates/db/.sqlx/query-532d942893f3449104e53c413dbae1846543d178054863b3720dbd11fb8b8ba2.json create mode 100644 crates/db/.sqlx/query-a05be875280bac75e75ffd5b6a083df59c614536ff62ca8fe2b8fa4e0f49b4bb.json create mode 100644 crates/db/.sqlx/query-a64c1c44cca8852089cdd2f1eb0294ecd56b982458ab7f1f5ed493a941975ab8.json delete mode 100644 crates/db/.sqlx/query-ce1b3af7d1bc00ea52648cc2649e0f5a0f118b0fe5919979c6cdb85589b4d302.json delete mode 100644 crates/db/.sqlx/query-e45b7815888b93d10ae9dd19bbe8adada965748dad9aa0ed6c08a812827c273b.json create mode 100644 crates/db/migrations/20260721102000_add_queue_paste_claim_owner.sql diff --git a/crates/db/.sqlx/query-23088d611e12e7bf78b08417a886b1821c406b8063d2137314d9920914bb2a2e.json b/crates/db/.sqlx/query-23088d611e12e7bf78b08417a886b1821c406b8063d2137314d9920914bb2a2e.json deleted file mode 100644 index 62eb69ebb1c..00000000000 --- a/crates/db/.sqlx/query-23088d611e12e7bf78b08417a886b1821c406b8063d2137314d9920914bb2a2e.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'pasting',\n claude_session_id = $1,\n executor_claim_owner = $2,\n pasted_at = CASE WHEN $2 IS NULL\n THEN datetime('now', 'subsec')\n ELSE NULL END,\n failure_reason = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $3 AND state = 'queued'", - "describe": { - "columns": [], - "parameters": { - "Right": 3 - }, - "nullable": [] - }, - "hash": "23088d611e12e7bf78b08417a886b1821c406b8063d2137314d9920914bb2a2e" -} diff --git a/crates/db/.sqlx/query-356d3e35103367646b7b0d7a2428a504daae2175653d0baa8194703939f05a6b.json b/crates/db/.sqlx/query-356d3e35103367646b7b0d7a2428a504daae2175653d0baa8194703939f05a6b.json new file mode 100644 index 00000000000..ee762de4d78 --- /dev/null +++ b/crates/db/.sqlx/query-356d3e35103367646b7b0d7a2428a504daae2175653d0baa8194703939f05a6b.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = $1,\n executor_claim_owner = NULL,\n paste_claim_owner = NULL,\n claude_session_id = CASE\n WHEN state = 'pasted' THEN claude_session_id\n ELSE NULL\n END,\n pasted_at = CASE\n WHEN state = 'pasted' THEN pasted_at\n ELSE NULL\n END,\n acked_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $2 AND state IN ('pasting', 'pasted')", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "356d3e35103367646b7b0d7a2428a504daae2175653d0baa8194703939f05a6b" +} diff --git a/crates/db/.sqlx/query-3e632219d6e2d07d1dbfe0986b654ca46e6eeec49d58ac95d558f5aa46ac12f9.json b/crates/db/.sqlx/query-3e632219d6e2d07d1dbfe0986b654ca46e6eeec49d58ac95d558f5aa46ac12f9.json new file mode 100644 index 00000000000..09f642c61a9 --- /dev/null +++ b/crates/db/.sqlx/query-3e632219d6e2d07d1dbfe0986b654ca46e6eeec49d58ac95d558f5aa46ac12f9.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'pasted',\n paste_claim_owner = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $1 AND state = 'pasting'\n AND executor_claim_owner IS NULL\n AND paste_claim_owner = $2", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "3e632219d6e2d07d1dbfe0986b654ca46e6eeec49d58ac95d558f5aa46ac12f9" +} diff --git a/crates/db/.sqlx/query-532d942893f3449104e53c413dbae1846543d178054863b3720dbd11fb8b8ba2.json b/crates/db/.sqlx/query-532d942893f3449104e53c413dbae1846543d178054863b3720dbd11fb8b8ba2.json deleted file mode 100644 index 158006ee1e7..00000000000 --- a/crates/db/.sqlx/query-532d942893f3449104e53c413dbae1846543d178054863b3720dbd11fb8b8ba2.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = $1,\n executor_claim_owner = NULL,\n claude_session_id = CASE\n WHEN state = 'pasted' THEN claude_session_id\n ELSE NULL\n END,\n pasted_at = CASE\n WHEN state = 'pasted' THEN pasted_at\n ELSE NULL\n END,\n acked_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $2 AND state IN ('pasting', 'pasted')", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "532d942893f3449104e53c413dbae1846543d178054863b3720dbd11fb8b8ba2" -} diff --git a/crates/db/.sqlx/query-a05be875280bac75e75ffd5b6a083df59c614536ff62ca8fe2b8fa4e0f49b4bb.json b/crates/db/.sqlx/query-a05be875280bac75e75ffd5b6a083df59c614536ff62ca8fe2b8fa4e0f49b4bb.json new file mode 100644 index 00000000000..acef58f0922 --- /dev/null +++ b/crates/db/.sqlx/query-a05be875280bac75e75ffd5b6a083df59c614536ff62ca8fe2b8fa4e0f49b4bb.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'pasting',\n claude_session_id = $1,\n executor_claim_owner = $2,\n paste_claim_owner = $3,\n pasted_at = CASE WHEN $3 IS NOT NULL\n THEN datetime('now', 'subsec')\n ELSE NULL END,\n failure_reason = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $4 AND state = 'queued'", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "a05be875280bac75e75ffd5b6a083df59c614536ff62ca8fe2b8fa4e0f49b4bb" +} diff --git a/crates/db/.sqlx/query-a64c1c44cca8852089cdd2f1eb0294ecd56b982458ab7f1f5ed493a941975ab8.json b/crates/db/.sqlx/query-a64c1c44cca8852089cdd2f1eb0294ecd56b982458ab7f1f5ed493a941975ab8.json new file mode 100644 index 00000000000..aab6afa3cc3 --- /dev/null +++ b/crates/db/.sqlx/query-a64c1c44cca8852089cdd2f1eb0294ecd56b982458ab7f1f5ed493a941975ab8.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = CASE\n WHEN pasted_at IS NOT NULL THEN $3\n ELSE 'delivery interrupted; queued for retry'\n END,\n claude_session_id = CASE\n WHEN pasted_at IS NOT NULL THEN claude_session_id\n ELSE NULL\n END,\n executor_claim_owner = NULL,\n paste_claim_owner = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE state = 'pasting'\n AND (\n (executor_claim_owner IS NULL\n AND paste_claim_owner IS NULL\n AND julianday(updated_at) <= julianday($1))\n OR (executor_claim_owner IS NOT NULL\n AND ($2 IS NULL OR executor_claim_owner != $2))\n OR (paste_claim_owner IS NOT NULL\n AND ($4 IS NULL OR paste_claim_owner != $4))\n )", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "a64c1c44cca8852089cdd2f1eb0294ecd56b982458ab7f1f5ed493a941975ab8" +} diff --git a/crates/db/.sqlx/query-ce1b3af7d1bc00ea52648cc2649e0f5a0f118b0fe5919979c6cdb85589b4d302.json b/crates/db/.sqlx/query-ce1b3af7d1bc00ea52648cc2649e0f5a0f118b0fe5919979c6cdb85589b4d302.json deleted file mode 100644 index bde66ee4e37..00000000000 --- a/crates/db/.sqlx/query-ce1b3af7d1bc00ea52648cc2649e0f5a0f118b0fe5919979c6cdb85589b4d302.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'pasted',\n updated_at = datetime('now', 'subsec')\n WHERE id = $1 AND state = 'pasting'\n AND executor_claim_owner IS NULL", - "describe": { - "columns": [], - "parameters": { - "Right": 1 - }, - "nullable": [] - }, - "hash": "ce1b3af7d1bc00ea52648cc2649e0f5a0f118b0fe5919979c6cdb85589b4d302" -} diff --git a/crates/db/.sqlx/query-e45b7815888b93d10ae9dd19bbe8adada965748dad9aa0ed6c08a812827c273b.json b/crates/db/.sqlx/query-e45b7815888b93d10ae9dd19bbe8adada965748dad9aa0ed6c08a812827c273b.json deleted file mode 100644 index 819fc65e4c0..00000000000 --- a/crates/db/.sqlx/query-e45b7815888b93d10ae9dd19bbe8adada965748dad9aa0ed6c08a812827c273b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = 'delivery interrupted; queued for retry',\n claude_session_id = NULL,\n executor_claim_owner = NULL,\n pasted_at = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE state = 'pasting'\n AND (\n (executor_claim_owner IS NULL\n AND julianday(updated_at) <= julianday($1))\n OR (executor_claim_owner IS NOT NULL\n AND ($2 IS NULL OR executor_claim_owner != $2))\n )", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "e45b7815888b93d10ae9dd19bbe8adada965748dad9aa0ed6c08a812827c273b" -} diff --git a/crates/db/migrations/20260721102000_add_queue_paste_claim_owner.sql b/crates/db/migrations/20260721102000_add_queue_paste_claim_owner.sql new file mode 100644 index 00000000000..9dcff0f26d8 --- /dev/null +++ b/crates/db/migrations/20260721102000_add_queue_paste_claim_owner.sql @@ -0,0 +1,5 @@ +-- Paste delivery can outlast the startup grace just like executor dispatch. +-- A process-scoped owner lets reconciliation distinguish a live paste from a +-- claim abandoned by a crashed server. +ALTER TABLE session_queued_messages + ADD COLUMN paste_claim_owner TEXT; diff --git a/crates/db/src/models/session_queued_message.rs b/crates/db/src/models/session_queued_message.rs index 63448c1cf0b..06fe210f8eb 100644 --- a/crates/db/src/models/session_queued_message.rs +++ b/crates/db/src/models/session_queued_message.rs @@ -47,6 +47,9 @@ pub struct SessionQueuedMessage { pub executor_claim_owner: Option, #[serde(skip)] #[ts(skip)] + pub paste_claim_owner: Option, + #[serde(skip)] + #[ts(skip)] pub dispatch_context: Option, pub pasted_at: Option>, pub acked_at: Option>, @@ -77,7 +80,8 @@ pub struct QueueReconciliation { impl SessionQueuedMessage { const SELECT_FIELDS: &'static str = r#" id, session_id, prompt, executor_config, source, state, - failure_reason, claude_session_id, executor_claim_owner, dispatch_context, + failure_reason, claude_session_id, executor_claim_owner, paste_claim_owner, + dispatch_context, pasted_at, acked_at, created_at, updated_at "#; @@ -172,6 +176,7 @@ impl SessionQueuedMessage { failure_reason = NULL, claude_session_id = NULL, executor_claim_owner = NULL, + paste_claim_owner = NULL, dispatch_context = $4, pasted_at = NULL, acked_at = NULL, @@ -222,20 +227,23 @@ impl SessionQueuedMessage { id: Uuid, claude_session_id: Option<&str>, executor_claim_owner: Option<&str>, + paste_claim_owner: Option<&str>, ) -> Result, sqlx::Error> { let result = sqlx::query!( r#"UPDATE session_queued_messages SET state = 'pasting', claude_session_id = $1, executor_claim_owner = $2, - pasted_at = CASE WHEN $2 IS NULL + paste_claim_owner = $3, + pasted_at = CASE WHEN $3 IS NOT NULL THEN datetime('now', 'subsec') ELSE NULL END, failure_reason = NULL, updated_at = datetime('now', 'subsec') - WHERE id = $3 AND state = 'queued'"#, + WHERE id = $4 AND state = 'queued'"#, claude_session_id, executor_claim_owner, + paste_claim_owner, id ) .execute(pool) @@ -251,8 +259,9 @@ impl SessionQueuedMessage { pool: &SqlitePool, id: Uuid, claude_session_id: Option<&str>, + owner: &str, ) -> Result, sqlx::Error> { - Self::claim(pool, id, claude_session_id, None).await + Self::claim(pool, id, claude_session_id, None, Some(owner)).await } /// Claim a queued row for executor delivery. The process-scoped owner is @@ -262,17 +271,24 @@ impl SessionQueuedMessage { id: Uuid, owner: &str, ) -> Result, sqlx::Error> { - Self::claim(pool, id, None, Some(owner)).await + Self::claim(pool, id, None, Some(owner), None).await } - pub async fn mark_pasted(pool: &SqlitePool, id: Uuid) -> Result { + pub async fn mark_pasted( + pool: &SqlitePool, + id: Uuid, + owner: &str, + ) -> Result { let result = sqlx::query!( r#"UPDATE session_queued_messages SET state = 'pasted', + paste_claim_owner = NULL, updated_at = datetime('now', 'subsec') WHERE id = $1 AND state = 'pasting' - AND executor_claim_owner IS NULL"#, - id + AND executor_claim_owner IS NULL + AND paste_claim_owner = $2"#, + id, + owner ) .execute(pool) .await?; @@ -289,6 +305,7 @@ impl SessionQueuedMessage { state = 'queued', failure_reason = $1, executor_claim_owner = NULL, + paste_claim_owner = NULL, claude_session_id = CASE WHEN state = 'pasted' THEN claude_session_id ELSE NULL @@ -386,6 +403,7 @@ impl SessionQueuedMessage { pasting_grace: Duration, paste_ack_timeout: Duration, active_executor_claim_owner: Option<&str>, + active_paste_claim_owner: Option<&str>, ) -> Result { let imported = sqlx::query!( r#"UPDATE session_queued_messages SET @@ -414,20 +432,31 @@ impl SessionQueuedMessage { let requeued_pasting = sqlx::query!( r#"UPDATE session_queued_messages SET state = 'queued', - failure_reason = 'delivery interrupted; queued for retry', - claude_session_id = NULL, + failure_reason = CASE + WHEN pasted_at IS NOT NULL THEN $3 + ELSE 'delivery interrupted; queued for retry' + END, + claude_session_id = CASE + WHEN pasted_at IS NOT NULL THEN claude_session_id + ELSE NULL + END, executor_claim_owner = NULL, - pasted_at = NULL, + paste_claim_owner = NULL, updated_at = datetime('now', 'subsec') WHERE state = 'pasting' AND ( (executor_claim_owner IS NULL + AND paste_claim_owner IS NULL AND julianday(updated_at) <= julianday($1)) OR (executor_claim_owner IS NOT NULL AND ($2 IS NULL OR executor_claim_owner != $2)) + OR (paste_claim_owner IS NOT NULL + AND ($4 IS NULL OR paste_claim_owner != $4)) )"#, pasting_cutoff, - active_executor_claim_owner + active_executor_claim_owner, + PASTED_REQUEUE_FAILURE_REASON, + active_paste_claim_owner ) .execute(pool) .await? @@ -462,6 +491,11 @@ mod tests { use super::*; use crate::models::{ + claude_session_link::ClaudeSessionLink, + cli_native_file::{CliNativeFile, RegisterCliNativeFile}, + cli_native_record::{ + CliNativeRecord, CliNativeRecordDisposition, ImportedCursor, NewCliNativeRecord, + }, session::{CreateSession, Session}, workspace::{CreateWorkspace, Workspace}, }; @@ -550,7 +584,7 @@ mod tests { && row.source == QueuedMessageSource::Recovery )); - SessionQueuedMessage::claim_for_paste(&pool, first.id, Some("sid")) + SessionQueuedMessage::claim_for_paste(&pool, first.id, Some("sid"), "paste-owner") .await .unwrap() .unwrap(); @@ -669,7 +703,7 @@ mod tests { StoreQueuedMessageResult::Stored(row) => row, StoreQueuedMessageResult::Conflict(_) => unreachable!(), }; - SessionQueuedMessage::claim_for_paste(&pool, row.id, Some("sid")) + SessionQueuedMessage::claim_for_paste(&pool, row.id, Some("sid"), "paste-owner") .await .unwrap(); let old = Utc::now() - Duration::seconds(10); @@ -685,6 +719,7 @@ mod tests { Duration::seconds(5), Duration::seconds(30), None, + None, ) .await .unwrap(); @@ -698,10 +733,10 @@ mod tests { QueuedMessageState::Queued ); - SessionQueuedMessage::claim_for_paste(&pool, row.id, Some("sid")) + SessionQueuedMessage::claim_for_paste(&pool, row.id, Some("sid"), "paste-owner") .await .unwrap(); - SessionQueuedMessage::mark_pasted(&pool, row.id) + SessionQueuedMessage::mark_pasted(&pool, row.id, "paste-owner") .await .unwrap(); let old = Utc::now() - Duration::seconds(31); @@ -717,6 +752,7 @@ mod tests { Duration::seconds(5), Duration::minutes(15), None, + None, ) .await .unwrap(); @@ -743,6 +779,7 @@ mod tests { Duration::seconds(5), Duration::minutes(15), None, + None, ) .await .unwrap(); @@ -796,6 +833,7 @@ mod tests { Duration::seconds(5), Duration::minutes(15), Some("runtime-a"), + None, ) .await .unwrap(); @@ -807,6 +845,7 @@ mod tests { Duration::seconds(5), Duration::minutes(15), Some("runtime-b"), + None, ) .await .unwrap(); @@ -820,4 +859,129 @@ mod tests { QueuedMessageState::Queued ); } + + #[tokio::test] + async fn abandoned_pasting_requeue_preserves_evidence_for_late_ack() { + let (pool, session_id) = session_fixture().await; + let sid = "43434343-4343-4343-8343-434343434343"; + let prompt = "ack an abandoned paste"; + ClaudeSessionLink::assign_manual(&pool, sid, session_id, "/tmp/abandoned-paste") + .await + .unwrap() + .unwrap(); + let row = match SessionQueuedMessage::store( + &pool, + session_id, + prompt, + None, + QueuedMessageSource::Ui, + false, + ) + .await + .unwrap() + { + StoreQueuedMessageResult::Stored(row) => row, + StoreQueuedMessageResult::Conflict(_) => unreachable!(), + }; + let claimed = + SessionQueuedMessage::claim_for_paste(&pool, row.id, Some(sid), "abandoned-owner") + .await + .unwrap() + .unwrap(); + let pasted_at = claimed.pasted_at.unwrap(); + let old = Utc::now() - Duration::seconds(10); + sqlx::query("UPDATE session_queued_messages SET updated_at = ? WHERE id = ?") + .bind(old) + .bind(row.id) + .execute(&pool) + .await + .unwrap(); + + let recovered = SessionQueuedMessage::reconcile( + &pool, + Utc::now(), + Duration::seconds(5), + Duration::minutes(15), + None, + None, + ) + .await + .unwrap(); + assert_eq!(recovered.requeued_pasting, 1); + let requeued = SessionQueuedMessage::find_by_id(&pool, row.id) + .await + .unwrap() + .unwrap(); + assert!(requeued.was_requeued_from_pasted()); + assert_eq!(requeued.pasted_at, Some(pasted_at)); + assert_eq!(requeued.claude_session_id.as_deref(), Some(sid)); + assert_eq!( + requeued.failure_reason.as_deref(), + Some(PASTED_REQUEUE_FAILURE_REASON) + ); + + let workspace_id = Session::find_by_id(&pool, session_id) + .await + .unwrap() + .unwrap() + .workspace_id; + let native_file = CliNativeFile::register( + &pool, + &RegisterCliNativeFile { + claude_session_id: sid, + dir_path: "/tmp/abandoned-paste-native", + file_name: "abandoned-paste.jsonl", + discovered_workspace_id: Some(workspace_id), + dev: 1, + inode: 43, + observed_size: 1, + observed_mtime_ms: None, + }, + ) + .await + .unwrap(); + let recorded_at = pasted_at + Duration::seconds(1); + CliNativeRecord::import_batch( + &pool, + native_file.id, + &[NewCliNativeRecord { + line_seq: 0, + claude_session_id: sid.to_string(), + uuid: Some("late-user-ack".to_string()), + parent_uuid: None, + kind: "user".to_string(), + ts: Some(recorded_at.to_rfc3339()), + raw: "{}".to_string(), + disposition: CliNativeRecordDisposition::Renderable, + user_prompt: Some(prompt.to_string()), + recorded_at: Some(recorded_at), + }], + &ImportedCursor { + cursor_offset: 1, + next_line_seq: 1, + last_line_offset: 0, + last_line_hash: None, + observed_size: 1, + observed_mtime_ms: None, + }, + ) + .await + .unwrap(); + + let acknowledged = SessionQueuedMessage::find_by_id(&pool, row.id) + .await + .unwrap() + .unwrap(); + assert_eq!(acknowledged.state, QueuedMessageState::Imported); + assert!(acknowledged.acked_at.is_some()); + let bound_queue_id: Option = sqlx::query_scalar( + "SELECT bound_queued_message_id FROM cli_native_records \ + WHERE file_id = ? AND line_seq = 0", + ) + .bind(native_file.id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(bound_queue_id, Some(row.id)); + } } diff --git a/crates/services/src/services/claude_transcript_ingest/tests.rs b/crates/services/src/services/claude_transcript_ingest/tests.rs index 6675017f03e..3ff0341c3d2 100644 --- a/crates/services/src/services/claude_transcript_ingest/tests.rs +++ b/crates/services/src/services/claude_transcript_ingest/tests.rs @@ -267,11 +267,11 @@ async fn pasted_slot( StoreQueuedMessageResult::Stored(row) => row, StoreQueuedMessageResult::Conflict(_) => panic!("fixture slot must be empty"), }; - SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid)) + SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid), "test-paste-owner") .await .unwrap() .unwrap(); - SessionQueuedMessage::mark_pasted(&db.pool, row.id) + SessionQueuedMessage::mark_pasted(&db.pool, row.id, "test-paste-owner") .await .unwrap(); SessionQueuedMessage::find_by_id(&db.pool, row.id) diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index d03981dbabf..790ce921bc9 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -149,6 +149,8 @@ pub enum CliCollabError { WorkspaceMissing(Uuid), #[error("queued message {0} lost its executor dispatch claim")] ExecutorClaimLost(Uuid), + #[error("queued message {0} lost its CLI paste claim")] + PasteClaimLost(Uuid), #[error("CLI transport failed for workspace {workspace_id}: {source}")] Transport { workspace_id: Uuid, @@ -166,6 +168,7 @@ pub struct CliCollabService { session_locks: Mutex>>>, resume_signaled_bindings: Mutex>, executor_claim_owner: String, + paste_claim_owner: String, notify: Notify, routing_disabled: bool, shutdown: CancellationToken, @@ -189,6 +192,7 @@ impl CliCollabService { session_locks: Mutex::new(HashMap::new()), resume_signaled_bindings: Mutex::new(HashSet::new()), executor_claim_owner: Uuid::new_v4().to_string(), + paste_claim_owner: Uuid::new_v4().to_string(), notify: Notify::new(), routing_disabled: std::env::var_os("DISABLE_CLI_COLLAB_ROUTING").is_some(), shutdown, @@ -629,8 +633,13 @@ impl CliCollabService { row: SessionQueuedMessage, claude_session_id: Option<&str>, ) -> Result { - let Some(claimed) = - SessionQueuedMessage::claim_for_paste(&self.db.pool, row.id, claude_session_id).await? + let Some(claimed) = SessionQueuedMessage::claim_for_paste( + &self.db.pool, + row.id, + claude_session_id, + &self.paste_claim_owner, + ) + .await? else { return Ok(DispatchOutcome::Conflict { status: self.status(session.id).await?, @@ -641,7 +650,15 @@ impl CliCollabService { .paste_and_submit(session.workspace_id, &claimed.prompt) .await { - SessionQueuedMessage::mark_pasted(&self.db.pool, claimed.id).await?; + if !SessionQueuedMessage::mark_pasted( + &self.db.pool, + claimed.id, + &self.paste_claim_owner, + ) + .await? + { + return Err(CliCollabError::PasteClaimLost(claimed.id)); + } let status = self.status(session.id).await?; Ok(DispatchOutcome::RoutedToCli { delivery: status }) } else { @@ -1163,6 +1180,7 @@ impl CliCollabService { PASTING_STARTUP_GRACE, PASTE_ACK_HARD_CAP, Some(&self.executor_claim_owner), + Some(&self.paste_claim_owner), ) .await { @@ -1504,6 +1522,36 @@ mod tests { } } + struct SlowPasteTransport { + calls: Arc, + started: Arc, + release: Arc, + } + + #[async_trait] + impl CliPasteTransport for SlowPasteTransport { + async fn paste_and_submit(&self, _workspace_id: Uuid, _text: &str) -> bool { + let call = self.calls.fetch_add(1, Ordering::SeqCst); + if call == 0 { + self.started.notify_one(); + self.release.notified().await; + } + true + } + + async fn pane_alive(&self, _workspace_id: Uuid) -> AnyhowResult { + Ok(true) + } + + async fn agent_running(&self, _workspace_id: Uuid) -> Option { + Some(true) + } + + async fn signal_resume_ready(&self, _workspace_id: Uuid, _sid: &str) -> AnyhowResult<()> { + Ok(()) + } + } + #[derive(Debug, Default)] struct ResumeTransportState { signals: Vec<(Uuid, String)>, @@ -1694,6 +1742,7 @@ mod tests { session_locks: Mutex::new(HashMap::new()), resume_signaled_bindings: Mutex::new(HashSet::new()), executor_claim_owner: Uuid::new_v4().to_string(), + paste_claim_owner: Uuid::new_v4().to_string(), notify: Notify::new(), routing_disabled: false, shutdown: CancellationToken::new(), @@ -2509,6 +2558,7 @@ mod tests { PASTING_STARTUP_GRACE, PASTE_ACK_HARD_CAP, Some(&service.executor_claim_owner), + Some(&service.paste_claim_owner), ) .await .unwrap(); @@ -2556,6 +2606,148 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 1); } + #[tokio::test] + async fn slow_paste_is_not_reconciled_or_submitted_twice() { + let (db, workspace, session) = fixture().await; + let sid = "41414141-4141-4141-8141-414141414141"; + bind_confirmed_cli(&db, &workspace, &session, sid).await; + let row = store_message( + &db, + session.id, + "paste exactly once", + QueuedMessageSource::Ui, + ) + .await; + let calls = Arc::new(AtomicUsize::new(0)); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let (service, _) = service_with_components( + db.clone(), + report( + true, + Some(true), + SidEvidence::ConfirmedResume(sid.to_string()), + ), + Arc::new(SlowPasteTransport { + calls: calls.clone(), + started: started.clone(), + release: release.clone(), + }), + Arc::new(NeverDispatcher), + ); + let service = Arc::new(service); + let draining = { + let service = service.clone(); + tokio::spawn(async move { service.drain_session(session.id).await }) + }; + + started.notified().await; + let older_than_paste_grace = + Utc::now() - PASTING_STARTUP_GRACE - ChronoDuration::seconds(1); + sqlx::query("UPDATE session_queued_messages SET updated_at = ? WHERE id = ?") + .bind(older_than_paste_grace) + .bind(row.id) + .execute(&db.pool) + .await + .unwrap(); + service.reconcile_delivery_state_from_db().await; + + release.notify_one(); + assert!(draining.await.unwrap().unwrap()); + let _ = service.drain_session(session.id).await.unwrap(); + + let native_file = CliNativeFile::register( + &db.pool, + &RegisterCliNativeFile { + claude_session_id: sid, + dir_path: "/tmp/slow-paste-native", + file_name: "slow-paste.jsonl", + discovered_workspace_id: Some(workspace.id), + dev: 1, + inode: 41, + observed_size: 1, + observed_mtime_ms: None, + }, + ) + .await + .unwrap(); + sqlx::query( + "INSERT INTO cli_native_records \ + (file_id, line_seq, claude_session_id, uuid, kind, raw, \ + disposition, bound_queued_message_id) \ + VALUES (?, 0, ?, ?, 'user', '{}', 'renderable', ?)", + ) + .bind(native_file.id) + .bind(sid) + .bind("slow-paste-user") + .bind(row.id) + .execute(&db.pool) + .await + .unwrap(); + service.reconcile_delivery_state_from_db().await; + + assert_eq!(calls.load(Ordering::SeqCst), 1); + let delivered = SessionQueuedMessage::find_by_id(&db.pool, row.id) + .await + .unwrap() + .unwrap(); + assert_eq!(delivered.state, QueuedMessageState::Imported); + assert!(delivered.acked_at.is_some()); + } + + #[tokio::test] + async fn paste_slot_does_not_report_delivery_after_losing_its_claim() { + let (db, workspace, session) = fixture().await; + let sid = "42424242-4242-4242-8242-424242424242"; + bind_confirmed_cli(&db, &workspace, &session, sid).await; + let row = store_message(&db, session.id, "lose paste claim", QueuedMessageSource::Ui).await; + let calls = Arc::new(AtomicUsize::new(0)); + let started = Arc::new(Notify::new()); + let release = Arc::new(Notify::new()); + let (service, _) = service_with_components( + db.clone(), + report( + true, + Some(true), + SidEvidence::ConfirmedResume(sid.to_string()), + ), + Arc::new(SlowPasteTransport { + calls: calls.clone(), + started: started.clone(), + release: release.clone(), + }), + Arc::new(NeverDispatcher), + ); + let service = Arc::new(service); + let draining = { + let service = service.clone(); + tokio::spawn(async move { service.drain_session(session.id).await }) + }; + + started.notified().await; + assert!( + SessionQueuedMessage::requeue(&db.pool, row.id, Some("claim cancelled")) + .await + .unwrap() + ); + release.notify_one(); + + let result = draining.await.unwrap(); + assert!(matches!( + result, + Err(CliCollabError::PasteClaimLost(id)) if id == row.id + )); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!( + SessionQueuedMessage::find_by_id(&db.pool, row.id) + .await + .unwrap() + .unwrap() + .state, + QueuedMessageState::Queued + ); + } + #[tokio::test] async fn replace_contract_preserves_conflict_details_and_rejects_in_flight_replace() { let (db, _workspace, session) = fixture().await; @@ -2612,12 +2804,12 @@ mod tests { assert_eq!(replaced_message.data.message, "new UI prompt"); assert_eq!(replaced_message.source, QueuedMessageSource::Ui); - SessionQueuedMessage::claim_for_paste(&db.pool, first_id, Some("sid")) + SessionQueuedMessage::claim_for_paste(&db.pool, first_id, Some("sid"), "test-paste-owner") .await .unwrap() .unwrap(); assert!( - SessionQueuedMessage::mark_pasted(&db.pool, first_id) + SessionQueuedMessage::mark_pasted(&db.pool, first_id, "test-paste-owner") .await .unwrap() ); @@ -2652,11 +2844,11 @@ mod tests { QueuedMessageSource::Ui, ) .await; - SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid)) + SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid), "test-paste-owner") .await .unwrap() .unwrap(); - SessionQueuedMessage::mark_pasted(&db.pool, row.id) + SessionQueuedMessage::mark_pasted(&db.pool, row.id, "test-paste-owner") .await .unwrap(); sqlx::query( @@ -2718,11 +2910,11 @@ mod tests { QueuedMessageSource::Recovery, ) .await; - SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid)) + SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid), "test-paste-owner") .await .unwrap() .unwrap(); - SessionQueuedMessage::mark_pasted(&db.pool, row.id) + SessionQueuedMessage::mark_pasted(&db.pool, row.id, "test-paste-owner") .await .unwrap(); let pasted_at = SessionQueuedMessage::find_by_id(&db.pool, row.id) @@ -2766,11 +2958,11 @@ mod tests { QueuedMessageSource::Ui, ) .await; - SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid)) + SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid), "test-paste-owner") .await .unwrap() .unwrap(); - SessionQueuedMessage::mark_pasted(&db.pool, row.id) + SessionQueuedMessage::mark_pasted(&db.pool, row.id, "test-paste-owner") .await .unwrap(); sqlx::query( @@ -2834,11 +3026,11 @@ mod tests { QueuedMessageSource::Ui, ) .await; - SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid)) + SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid), "test-paste-owner") .await .unwrap() .unwrap(); - SessionQueuedMessage::mark_pasted(&db.pool, row.id) + SessionQueuedMessage::mark_pasted(&db.pool, row.id, "test-paste-owner") .await .unwrap(); SessionQueuedMessage::requeue_pasted(&db.pool, row.id) @@ -2911,10 +3103,15 @@ mod tests { QueuedMessageSource::Recovery, ) .await; - SessionQueuedMessage::claim_for_paste(&db.pool, interrupted.id, Some("interrupted-sid")) - .await - .unwrap() - .unwrap(); + SessionQueuedMessage::claim_for_paste( + &db.pool, + interrupted.id, + Some("interrupted-sid"), + "crashed-paste-owner", + ) + .await + .unwrap() + .unwrap(); sqlx::query( "UPDATE session_queued_messages \ SET updated_at = datetime('now', '-6 seconds') WHERE id = ?", @@ -2931,11 +3128,16 @@ mod tests { QueuedMessageSource::Ui, ) .await; - SessionQueuedMessage::claim_for_paste(&db.pool, acknowledged.id, Some("acknowledged-sid")) - .await - .unwrap() - .unwrap(); - SessionQueuedMessage::mark_pasted(&db.pool, acknowledged.id) + SessionQueuedMessage::claim_for_paste( + &db.pool, + acknowledged.id, + Some("acknowledged-sid"), + "test-paste-owner", + ) + .await + .unwrap() + .unwrap(); + SessionQueuedMessage::mark_pasted(&db.pool, acknowledged.id, "test-paste-owner") .await .unwrap(); let native_file = CliNativeFile::register( From fa1ecd77f07c3b23e603e263dc1f9d740ceed935 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 00:55:31 +0000 Subject: [PATCH 30/36] test(collab): cover live probe sid evidence --- crates/local-deployment/src/writer_probe.rs | 50 ++++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/local-deployment/src/writer_probe.rs b/crates/local-deployment/src/writer_probe.rs index e11adce110d..7fecca387d2 100644 --- a/crates/local-deployment/src/writer_probe.rs +++ b/crates/local-deployment/src/writer_probe.rs @@ -60,6 +60,8 @@ fn resume_evidence(cmdlines: &[String]) -> SidEvidence { fn live_process_report( processes: &[CliPaneAgentProcess], only_active_claude_in_cwd: Option, + _expected_sid: Option<&str>, + _binding: Option<&CliPaneBinding>, ) -> ProbeReport { let agent_running = !processes.is_empty(); let cmdlines: Vec<_> = processes @@ -140,7 +142,7 @@ impl CliWriterProbe for LocalCliWriterProbe { &self, workspace_id: Uuid, effective_dir: &Path, - _expected_sid: Option<&str>, + expected_sid: Option<&str>, binding: Option<&CliPaneBinding>, check_cwd_uniqueness: bool, ) -> ProbeReport { @@ -191,7 +193,7 @@ impl CliWriterProbe for LocalCliWriterProbe { } } }; - live_process_report(&processes, cwd_uniqueness) + live_process_report(&processes, cwd_uniqueness, expected_sid, binding) } } @@ -229,6 +231,8 @@ mod tests { cmdline: format!("claude --resume {observed}"), }], None, + None, + None, ); assert_eq!( mismatched.sid_evidence, @@ -245,6 +249,8 @@ mod tests { cmdline: "claude --model opus".to_string(), }], None, + None, + None, ); assert_eq!(no_resume.sid_evidence, SidEvidence::NoResumeArg); assert_ne!( @@ -252,4 +258,44 @@ mod tests { SidEvidence::ConfirmedResume(expected.to_string()) ); } + + #[test] + fn probe_path_never_replaces_live_evidence_with_matching_database_sid() { + let expected = "11111111-1111-4111-8111-111111111111"; + let observed = "22222222-2222-4222-8222-222222222222"; + let binding = CliPaneBinding { + id: Uuid::new_v4(), + workspace_id: Uuid::new_v4(), + session_id: Uuid::new_v4(), + claude_session_id: Some(expected.to_string()), + bound_via: db::models::cli_pane_binding::CliPaneBoundVia::CliResume, + created_at: chrono::Utc::now(), + released_at: None, + }; + + let mismatched = live_process_report( + &[CliPaneAgentProcess { + pid: 44, + cmdline: format!("claude --resume {observed}"), + }], + None, + Some(expected), + Some(&binding), + ); + assert_eq!( + mismatched.sid_evidence, + SidEvidence::ConfirmedResume(observed.to_string()) + ); + + let no_resume = live_process_report( + &[CliPaneAgentProcess { + pid: 45, + cmdline: "claude --model opus".to_string(), + }], + None, + Some(expected), + Some(&binding), + ); + assert_eq!(no_resume.sid_evidence, SidEvidence::NoResumeArg); + } } From d83a5ea59e35672524de683f3e3d408407d178b5 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 01:01:57 +0000 Subject: [PATCH 31/36] test(retry): cover local dispatch reset threading --- crates/local-deployment/src/container.rs | 223 +++++++++++++++++++++++ 1 file changed, 223 insertions(+) diff --git a/crates/local-deployment/src/container.rs b/crates/local-deployment/src/container.rs index 1decaa0f973..0c99b620d46 100644 --- a/crates/local-deployment/src/container.rs +++ b/crates/local-deployment/src/container.rs @@ -1775,7 +1775,17 @@ fn success_exit_status() -> std::process::ExitStatus { #[cfg(test)] mod tests { + use db::models::{ + coding_agent_turn::CreateCodingAgentTurn, + execution_process::CreateExecutionProcess, + repo::Repo, + session::CreateSession, + workspace::CreateWorkspace, + workspace_repo::{CreateWorkspaceRepo, WorkspaceRepo}, + }; use executors::{executors::BaseCodingAgent, profile::ExecutorConfig}; + use sqlx::sqlite::SqlitePoolOptions; + use tempfile::TempDir; use super::*; @@ -1803,4 +1813,217 @@ mod tests { Some(reset_to_message_id.as_str()) ); } + + #[tokio::test] + async fn local_retry_dispatch_threads_reset_id_into_follow_up_request() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + db::run_migrations_for_tests(&pool).await.unwrap(); + let db = DBService { pool }; + let temp = TempDir::new().unwrap(); + let source_repo_path = temp.path().join("source-repo"); + let workspace_root = temp.path().join("workspace"); + let git = GitService::new(); + git.initialize_repo_with_main_branch(&source_repo_path) + .unwrap(); + let repo = Repo::find_or_create(&db.pool, &source_repo_path, "source repo") + .await + .unwrap(); + + let workspace_id = Uuid::new_v4(); + Workspace::create( + &db.pool, + &CreateWorkspace { + branch: "retry-call-site".to_string(), + name: Some("retry call-site fixture".to_string()), + }, + workspace_id, + ) + .await + .unwrap(); + WorkspaceRepo::create_many( + &db.pool, + workspace_id, + &[CreateWorkspaceRepo { + repo_id: repo.id, + target_branch: "main".to_string(), + }], + ) + .await + .unwrap(); + Workspace::update_container_ref(&db.pool, workspace_id, &workspace_root.to_string_lossy()) + .await + .unwrap(); + let session = Session::create( + &db.pool, + &CreateSession { + executor: Some("CLAUDE_CODE".to_string()), + name: None, + }, + Uuid::new_v4(), + workspace_id, + ) + .await + .unwrap(); + + let base_process = ExecutionProcess::create( + &db.pool, + &CreateExecutionProcess { + session_id: session.id, + executor_action: ExecutorAction::new( + ExecutorActionType::CodingAgentInitialRequest(CodingAgentInitialRequest { + prompt: "base turn".to_string(), + executor_config: ExecutorConfig::new(BaseCodingAgent::ClaudeCode), + working_dir: None, + }), + None, + ), + run_reason: ExecutionProcessRunReason::CodingAgent, + }, + Uuid::new_v4(), + &[], + ) + .await + .unwrap(); + CodingAgentTurn::create( + &db.pool, + &CreateCodingAgentTurn { + execution_process_id: base_process.id, + prompt: Some("base turn".to_string()), + }, + Uuid::new_v4(), + ) + .await + .unwrap(); + CodingAgentTurn::update_agent_session_id(&db.pool, base_process.id, "claude-session") + .await + .unwrap(); + ExecutionProcess::update_completion( + &db.pool, + base_process.id, + ExecutionProcessStatus::Completed, + Some(0), + ) + .await + .unwrap(); + sqlx::query("UPDATE execution_processes SET created_at = datetime('now', '-2 seconds') WHERE id = ?") + .bind(base_process.id) + .execute(&db.pool) + .await + .unwrap(); + + let reset_to_message_id = "assistant-message-to-retry".to_string(); + let retry_process = ExecutionProcess::create( + &db.pool, + &CreateExecutionProcess { + session_id: session.id, + executor_action: ExecutorAction::new( + ExecutorActionType::CodingAgentFollowUpRequest(CodingAgentFollowUpRequest { + prompt: "turn being retried".to_string(), + session_id: "claude-session".to_string(), + reset_to_message_id: None, + executor_config: ExecutorConfig::new(BaseCodingAgent::ClaudeCode), + working_dir: None, + }), + None, + ), + run_reason: ExecutionProcessRunReason::CodingAgent, + }, + Uuid::new_v4(), + &[], + ) + .await + .unwrap(); + CodingAgentTurn::create( + &db.pool, + &CreateCodingAgentTurn { + execution_process_id: retry_process.id, + prompt: Some("turn being retried".to_string()), + }, + Uuid::new_v4(), + ) + .await + .unwrap(); + CodingAgentTurn::update_agent_session_id(&db.pool, retry_process.id, "claude-session") + .await + .unwrap(); + CodingAgentTurn::update_agent_message_id(&db.pool, retry_process.id, &reset_to_message_id) + .await + .unwrap(); + ExecutionProcess::update_completion( + &db.pool, + retry_process.id, + ExecutionProcessStatus::Completed, + Some(0), + ) + .await + .unwrap(); + + let config = Arc::new(RwLock::new(Config::default())); + let notification_service = NotificationService::new(config.clone()); + let service = LocalContainerService { + db: db.clone(), + workspace_manager: WorkspaceManager::new(db.clone()), + child_store: Arc::new(RwLock::new(HashMap::new())), + cancellation_tokens: Arc::new(RwLock::new(HashMap::new())), + msg_stores: Arc::new(RwLock::new(HashMap::new())), + db_stream_handles: Arc::new(RwLock::new(HashMap::new())), + exit_monitor_handles: Arc::new(RwLock::new(HashMap::new())), + workspace_touch_times: Arc::new(RwLock::new(HashMap::new())), + config, + git, + file_service: FileService::new(db.pool.clone()).unwrap(), + analytics: None, + approvals: Approvals::new(), + cli_collab: Arc::new(OnceLock::new()), + notification_service, + remote_client: None, + }; + let mut missing_profile = ExecutorConfig::new(BaseCodingAgent::ClaudeCode); + missing_profile.variant = Some("__MISSING_RETRY_CALL_SITE_TEST_PROFILE".to_string()); + let retry = RetryDispatchContext { + process_id: retry_process.id, + force_when_dirty: false, + perform_git_reset: false, + reset_to_message_id: Some(reset_to_message_id.clone()), + }; + + assert!( + CliExecutorDispatcher::dispatch( + &service, + &session, + "retry this prompt", + &missing_profile, + Some(&retry), + ) + .await + .is_err(), + "the missing test profile must stop after persisting the action" + ); + + let processes = ExecutionProcess::find_by_session_id(&db.pool, session.id, false) + .await + .unwrap(); + let dispatched = processes.last().unwrap(); + assert_ne!(dispatched.id, base_process.id); + let ExecutorActionType::CodingAgentFollowUpRequest(request) = + dispatched.executor_action().unwrap().typ() + else { + panic!("retry dispatch with session continuity must persist a follow-up"); + }; + assert_eq!(request.session_id, "claude-session"); + assert_eq!( + request.reset_to_message_id.as_deref(), + Some(reset_to_message_id.as_str()) + ); + + let log_path = utils::execution_logs::process_log_file_path(session.id, dispatched.id); + std::fs::remove_file(&log_path).unwrap(); + for directory in log_path.ancestors().skip(1).take(3) { + let _ = std::fs::remove_dir(directory); + } + } } From c088154f3529aa47489410d31507e736345bf458 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 01:55:40 +0000 Subject: [PATCH 32/36] fix(collab): hold orphaned executor requeues --- ...294ecd56b982458ab7f1f5ed493a941975ab8.json | 12 ---- ...3a174c18dbb8de3bee8f76a04575a1cdf8df6.json | 12 ++++ .../db/src/models/session_queued_message.rs | 23 ++++++- crates/services/src/services/cli_collab.rs | 63 ++++++++++++++++++- 4 files changed, 94 insertions(+), 16 deletions(-) delete mode 100644 crates/db/.sqlx/query-a64c1c44cca8852089cdd2f1eb0294ecd56b982458ab7f1f5ed493a941975ab8.json create mode 100644 crates/db/.sqlx/query-fb59cd2a391950015af42bccb793a174c18dbb8de3bee8f76a04575a1cdf8df6.json diff --git a/crates/db/.sqlx/query-a64c1c44cca8852089cdd2f1eb0294ecd56b982458ab7f1f5ed493a941975ab8.json b/crates/db/.sqlx/query-a64c1c44cca8852089cdd2f1eb0294ecd56b982458ab7f1f5ed493a941975ab8.json deleted file mode 100644 index aab6afa3cc3..00000000000 --- a/crates/db/.sqlx/query-a64c1c44cca8852089cdd2f1eb0294ecd56b982458ab7f1f5ed493a941975ab8.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = CASE\n WHEN pasted_at IS NOT NULL THEN $3\n ELSE 'delivery interrupted; queued for retry'\n END,\n claude_session_id = CASE\n WHEN pasted_at IS NOT NULL THEN claude_session_id\n ELSE NULL\n END,\n executor_claim_owner = NULL,\n paste_claim_owner = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE state = 'pasting'\n AND (\n (executor_claim_owner IS NULL\n AND paste_claim_owner IS NULL\n AND julianday(updated_at) <= julianday($1))\n OR (executor_claim_owner IS NOT NULL\n AND ($2 IS NULL OR executor_claim_owner != $2))\n OR (paste_claim_owner IS NOT NULL\n AND ($4 IS NULL OR paste_claim_owner != $4))\n )", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "a64c1c44cca8852089cdd2f1eb0294ecd56b982458ab7f1f5ed493a941975ab8" -} diff --git a/crates/db/.sqlx/query-fb59cd2a391950015af42bccb793a174c18dbb8de3bee8f76a04575a1cdf8df6.json b/crates/db/.sqlx/query-fb59cd2a391950015af42bccb793a174c18dbb8de3bee8f76a04575a1cdf8df6.json new file mode 100644 index 00000000000..7ca7b0d4877 --- /dev/null +++ b/crates/db/.sqlx/query-fb59cd2a391950015af42bccb793a174c18dbb8de3bee8f76a04575a1cdf8df6.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'queued',\n failure_reason = CASE\n WHEN executor_claim_owner IS NOT NULL\n AND ($2 IS NULL OR executor_claim_owner != $2)\n AND EXISTS (\n SELECT 1 FROM execution_processes process\n WHERE process.session_id = session_queued_messages.session_id\n AND process.run_reason = 'codingagent'\n AND process.status IN ('failed', 'killed')\n AND julianday(process.completed_at) >= MIN(\n julianday(session_queued_messages.updated_at),\n julianday($5)\n )\n ) THEN $6\n WHEN pasted_at IS NOT NULL THEN $3\n ELSE 'delivery interrupted; queued for retry'\n END,\n claude_session_id = CASE\n WHEN pasted_at IS NOT NULL THEN claude_session_id\n ELSE NULL\n END,\n executor_claim_owner = NULL,\n paste_claim_owner = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE state = 'pasting'\n AND (\n (executor_claim_owner IS NULL\n AND paste_claim_owner IS NULL\n AND julianday(updated_at) <= julianday($1))\n OR (executor_claim_owner IS NOT NULL\n AND ($2 IS NULL OR executor_claim_owner != $2))\n OR (paste_claim_owner IS NOT NULL\n AND ($4 IS NULL OR paste_claim_owner != $4))\n )", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "fb59cd2a391950015af42bccb793a174c18dbb8de3bee8f76a04575a1cdf8df6" +} diff --git a/crates/db/src/models/session_queued_message.rs b/crates/db/src/models/session_queued_message.rs index 06fe210f8eb..535635e1f70 100644 --- a/crates/db/src/models/session_queued_message.rs +++ b/crates/db/src/models/session_queued_message.rs @@ -7,6 +7,8 @@ use uuid::Uuid; pub const PASTED_REQUEUE_FAILURE_REASON: &str = "CLI submission was not acknowledged; queued for retry"; +pub const ABNORMAL_EXECUTOR_QUEUE_HOLD: &str = + "previous agent failed or was killed; confirm Send again to run this message"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type, TS)] #[sqlx(type_name = "TEXT", rename_all = "lowercase")] @@ -429,10 +431,27 @@ impl SessionQueuedMessage { .rows_affected(); let pasting_cutoff = now - pasting_grace; + // Executor owners are process-scoped UUIDs, so recovery cannot prove + // which execution abandoned a claim. Use the earlier of the claim + // timestamp and this tick's start as a conservative floor: a false + // hold costs an explicit resend, while a missed hold can run two + // coding agents against one worktree. let requeued_pasting = sqlx::query!( r#"UPDATE session_queued_messages SET state = 'queued', failure_reason = CASE + WHEN executor_claim_owner IS NOT NULL + AND ($2 IS NULL OR executor_claim_owner != $2) + AND EXISTS ( + SELECT 1 FROM execution_processes process + WHERE process.session_id = session_queued_messages.session_id + AND process.run_reason = 'codingagent' + AND process.status IN ('failed', 'killed') + AND julianday(process.completed_at) >= MIN( + julianday(session_queued_messages.updated_at), + julianday($5) + ) + ) THEN $6 WHEN pasted_at IS NOT NULL THEN $3 ELSE 'delivery interrupted; queued for retry' END, @@ -456,7 +475,9 @@ impl SessionQueuedMessage { pasting_cutoff, active_executor_claim_owner, PASTED_REQUEUE_FAILURE_REASON, - active_paste_claim_owner + active_paste_claim_owner, + now, + ABNORMAL_EXECUTOR_QUEUE_HOLD ) .execute(pool) .await? diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index 790ce921bc9..d504c630634 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -17,7 +17,8 @@ use db::{ execution_process::{ExecutionProcess, ExecutionProcessRunReason, ExecutionProcessStatus}, session::Session, session_queued_message::{ - QueuedMessageSource, QueuedMessageState, SessionQueuedMessage, StoreQueuedMessageResult, + ABNORMAL_EXECUTOR_QUEUE_HOLD, QueuedMessageSource, QueuedMessageState, + SessionQueuedMessage, StoreQueuedMessageResult, }, workspace::Workspace, workspace_spawn_reservation::{SpawnReservationHolder, WorkspaceSpawnReservation}, @@ -41,8 +42,6 @@ const RESUME_SIGNAL_ATTEMPTS: u32 = 3; const RESUME_SIGNAL_RETRY_DELAY: Duration = Duration::from_millis(100); const PASTING_STARTUP_GRACE: ChronoDuration = ChronoDuration::seconds(5); const PASTE_ACK_HARD_CAP: ChronoDuration = ChronoDuration::minutes(15); -const ABNORMAL_EXECUTOR_QUEUE_HOLD: &str = - "previous agent failed or was killed; confirm Send again to run this message"; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SidEvidence { @@ -2093,6 +2092,64 @@ mod tests { ); } + #[tokio::test] + async fn orphaned_executor_claim_requeue_remains_held_from_automatic_dispatch() { + let (db, _workspace, session) = fixture().await; + let row = store_message( + &db, + session.id, + "do not redispatch an orphaned claim", + QueuedMessageSource::Ui, + ) + .await; + SessionQueuedMessage::claim_for_executor(&db.pool, row.id, "pre-crash-owner") + .await + .unwrap() + .unwrap(); + let orphaned = create_running_executor(&db, session.id, "orphaned writer").await; + ExecutionProcess::update_completion( + &db.pool, + orphaned.id, + ExecutionProcessStatus::Failed, + None, + ) + .await + .unwrap(); + + let dispatcher_state = Arc::new(StdMutex::new(DispatcherState::default())); + let (service, _) = service_with_components( + db.clone(), + report(false, Some(false), SidEvidence::Unknown), + Arc::new(FakeTransport), + Arc::new(RecordingDispatcher { + db: db.clone(), + state: dispatcher_state.clone(), + }), + ); + + service.reconcile_delivery_state_from_db().await; + let held = SessionQueuedMessage::find_by_id(&db.pool, row.id) + .await + .unwrap() + .unwrap(); + assert_eq!(held.state, QueuedMessageState::Queued); + assert_eq!( + held.failure_reason.as_deref(), + Some(ABNORMAL_EXECUTOR_QUEUE_HOLD) + ); + + assert!(!service.drain_session(session.id).await.unwrap()); + assert!(dispatcher_state.lock().unwrap().prompts.is_empty()); + assert_eq!( + ExecutionProcess::find_by_session_id(&db.pool, session.id, true) + .await + .unwrap() + .len(), + 1, + "the held prompt must not create a replacement execution" + ); + } + #[tokio::test] async fn finish_hook_self_guards_a_non_releasing_chained_action() { let (db, _workspace, session) = fixture().await; From 7447487e0dd25bc1fa32cfed90cefc8293f35d28 Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 01:58:27 +0000 Subject: [PATCH 33/36] fix(collab): timestamp confirmed paste delivery --- ...54ca46e6eeec49d58ac95d558f5aa46ac12f9.json | 12 ---- ...863eb205d4bef1326d08e2967fc6d37002d03.json | 12 ++++ ...d0ee8895f52777258f86ed79e065b323fe2b3.json | 12 ++++ ...83df59c614536ff62ca8fe2b8fa4e0f49b4bb.json | 12 ---- .../db/src/models/session_queued_message.rs | 44 +++++++------- crates/services/src/services/cli_collab.rs | 57 +++++++++++++++++++ 6 files changed, 100 insertions(+), 49 deletions(-) delete mode 100644 crates/db/.sqlx/query-3e632219d6e2d07d1dbfe0986b654ca46e6eeec49d58ac95d558f5aa46ac12f9.json create mode 100644 crates/db/.sqlx/query-5465a5965d640433b16664cd251863eb205d4bef1326d08e2967fc6d37002d03.json create mode 100644 crates/db/.sqlx/query-83062d557f3c410f0f549d26484d0ee8895f52777258f86ed79e065b323fe2b3.json delete mode 100644 crates/db/.sqlx/query-a05be875280bac75e75ffd5b6a083df59c614536ff62ca8fe2b8fa4e0f49b4bb.json diff --git a/crates/db/.sqlx/query-3e632219d6e2d07d1dbfe0986b654ca46e6eeec49d58ac95d558f5aa46ac12f9.json b/crates/db/.sqlx/query-3e632219d6e2d07d1dbfe0986b654ca46e6eeec49d58ac95d558f5aa46ac12f9.json deleted file mode 100644 index 09f642c61a9..00000000000 --- a/crates/db/.sqlx/query-3e632219d6e2d07d1dbfe0986b654ca46e6eeec49d58ac95d558f5aa46ac12f9.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'pasted',\n paste_claim_owner = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $1 AND state = 'pasting'\n AND executor_claim_owner IS NULL\n AND paste_claim_owner = $2", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "3e632219d6e2d07d1dbfe0986b654ca46e6eeec49d58ac95d558f5aa46ac12f9" -} diff --git a/crates/db/.sqlx/query-5465a5965d640433b16664cd251863eb205d4bef1326d08e2967fc6d37002d03.json b/crates/db/.sqlx/query-5465a5965d640433b16664cd251863eb205d4bef1326d08e2967fc6d37002d03.json new file mode 100644 index 00000000000..c0a0117b432 --- /dev/null +++ b/crates/db/.sqlx/query-5465a5965d640433b16664cd251863eb205d4bef1326d08e2967fc6d37002d03.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'pasting',\n claude_session_id = $1,\n executor_claim_owner = $2,\n paste_claim_owner = $3,\n pasted_at = NULL,\n failure_reason = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $4 AND state = 'queued'", + "describe": { + "columns": [], + "parameters": { + "Right": 4 + }, + "nullable": [] + }, + "hash": "5465a5965d640433b16664cd251863eb205d4bef1326d08e2967fc6d37002d03" +} diff --git a/crates/db/.sqlx/query-83062d557f3c410f0f549d26484d0ee8895f52777258f86ed79e065b323fe2b3.json b/crates/db/.sqlx/query-83062d557f3c410f0f549d26484d0ee8895f52777258f86ed79e065b323fe2b3.json new file mode 100644 index 00000000000..d87347ae1ff --- /dev/null +++ b/crates/db/.sqlx/query-83062d557f3c410f0f549d26484d0ee8895f52777258f86ed79e065b323fe2b3.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "UPDATE session_queued_messages SET\n state = 'pasted',\n paste_claim_owner = NULL,\n pasted_at = datetime('now', 'subsec'),\n updated_at = datetime('now', 'subsec')\n WHERE id = $1 AND state = 'pasting'\n AND executor_claim_owner IS NULL\n AND paste_claim_owner = $2", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "83062d557f3c410f0f549d26484d0ee8895f52777258f86ed79e065b323fe2b3" +} diff --git a/crates/db/.sqlx/query-a05be875280bac75e75ffd5b6a083df59c614536ff62ca8fe2b8fa4e0f49b4bb.json b/crates/db/.sqlx/query-a05be875280bac75e75ffd5b6a083df59c614536ff62ca8fe2b8fa4e0f49b4bb.json deleted file mode 100644 index acef58f0922..00000000000 --- a/crates/db/.sqlx/query-a05be875280bac75e75ffd5b6a083df59c614536ff62ca8fe2b8fa4e0f49b4bb.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "UPDATE session_queued_messages SET\n state = 'pasting',\n claude_session_id = $1,\n executor_claim_owner = $2,\n paste_claim_owner = $3,\n pasted_at = CASE WHEN $3 IS NOT NULL\n THEN datetime('now', 'subsec')\n ELSE NULL END,\n failure_reason = NULL,\n updated_at = datetime('now', 'subsec')\n WHERE id = $4 AND state = 'queued'", - "describe": { - "columns": [], - "parameters": { - "Right": 4 - }, - "nullable": [] - }, - "hash": "a05be875280bac75e75ffd5b6a083df59c614536ff62ca8fe2b8fa4e0f49b4bb" -} diff --git a/crates/db/src/models/session_queued_message.rs b/crates/db/src/models/session_queued_message.rs index 535635e1f70..3c48f1d47dc 100644 --- a/crates/db/src/models/session_queued_message.rs +++ b/crates/db/src/models/session_queued_message.rs @@ -237,9 +237,7 @@ impl SessionQueuedMessage { claude_session_id = $1, executor_claim_owner = $2, paste_claim_owner = $3, - pasted_at = CASE WHEN $3 IS NOT NULL - THEN datetime('now', 'subsec') - ELSE NULL END, + pasted_at = NULL, failure_reason = NULL, updated_at = datetime('now', 'subsec') WHERE id = $4 AND state = 'queued'"#, @@ -285,6 +283,7 @@ impl SessionQueuedMessage { r#"UPDATE session_queued_messages SET state = 'pasted', paste_claim_owner = NULL, + pasted_at = datetime('now', 'subsec'), updated_at = datetime('now', 'subsec') WHERE id = $1 AND state = 'pasting' AND executor_claim_owner IS NULL @@ -882,7 +881,7 @@ mod tests { } #[tokio::test] - async fn abandoned_pasting_requeue_preserves_evidence_for_late_ack() { + async fn delivered_requeue_preserves_evidence_for_late_ack() { let (pool, session_id) = session_fixture().await; let sid = "43434343-4343-4343-8343-434343434343"; let prompt = "ack an abandoned paste"; @@ -904,31 +903,26 @@ mod tests { StoreQueuedMessageResult::Stored(row) => row, StoreQueuedMessageResult::Conflict(_) => unreachable!(), }; - let claimed = - SessionQueuedMessage::claim_for_paste(&pool, row.id, Some(sid), "abandoned-owner") + SessionQueuedMessage::claim_for_paste(&pool, row.id, Some(sid), "abandoned-owner") + .await + .unwrap() + .unwrap(); + assert!( + SessionQueuedMessage::mark_pasted(&pool, row.id, "abandoned-owner") .await .unwrap() - .unwrap(); - let pasted_at = claimed.pasted_at.unwrap(); - let old = Utc::now() - Duration::seconds(10); - sqlx::query("UPDATE session_queued_messages SET updated_at = ? WHERE id = ?") - .bind(old) - .bind(row.id) - .execute(&pool) + ); + let pasted_at = SessionQueuedMessage::find_by_id(&pool, row.id) .await + .unwrap() + .unwrap() + .pasted_at .unwrap(); - - let recovered = SessionQueuedMessage::reconcile( - &pool, - Utc::now(), - Duration::seconds(5), - Duration::minutes(15), - None, - None, - ) - .await - .unwrap(); - assert_eq!(recovered.requeued_pasting, 1); + assert!( + SessionQueuedMessage::requeue_pasted(&pool, row.id) + .await + .unwrap() + ); let requeued = SessionQueuedMessage::find_by_id(&pool, row.id) .await .unwrap() diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index d504c630634..3bcc977fc7c 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -2805,6 +2805,63 @@ mod tests { ); } + #[tokio::test] + async fn paste_claim_crash_before_delivery_requeues_without_false_evidence() { + let (db, workspace, session) = fixture().await; + let sid = "43434343-4343-4343-8343-434343434343"; + bind_confirmed_cli(&db, &workspace, &session, sid).await; + let row = store_message( + &db, + session.id, + "retry a paste that never reached the pane", + QueuedMessageSource::Ui, + ) + .await; + SessionQueuedMessage::claim_for_paste(&db.pool, row.id, Some(sid), "pre-crash-paste-owner") + .await + .unwrap() + .unwrap(); + + let transport_state = Arc::new(StdMutex::new(TransportState::default())); + let transport = Arc::new(RecordingTransport { + db: db.clone(), + session_id: session.id, + state: transport_state.clone(), + paste_succeeds: true, + }); + let (service, _) = service_with_components( + db.clone(), + report( + true, + Some(true), + SidEvidence::ConfirmedResume(sid.to_string()), + ), + transport, + Arc::new(NeverDispatcher), + ); + + service.reconcile_delivery_state_from_db().await; + let requeued = SessionQueuedMessage::find_by_id(&db.pool, row.id) + .await + .unwrap() + .unwrap(); + assert_eq!(requeued.state, QueuedMessageState::Queued); + assert!(requeued.pasted_at.is_none()); + assert!(!requeued.was_requeued_from_pasted()); + + assert!(service.drain_session(session.id).await.unwrap()); + assert_eq!( + transport_state.lock().unwrap().pasted_prompts, + ["retry a paste that never reached the pane"] + ); + let delivered = SessionQueuedMessage::find_by_id(&db.pool, row.id) + .await + .unwrap() + .unwrap(); + assert_eq!(delivered.state, QueuedMessageState::Pasted); + assert!(delivered.pasted_at.is_some()); + } + #[tokio::test] async fn replace_contract_preserves_conflict_details_and_rejects_in_flight_replace() { let (db, _workspace, session) = fixture().await; From 68cd154a8314d3c3c3c3182d835dd6140c294f3e Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 02:04:31 +0000 Subject: [PATCH 34/36] fix(retry): surface failures after destructive reset --- crates/local-deployment/src/container.rs | 52 ++++++---- crates/services/src/services/cli_collab.rs | 113 +++++++++++++++++++-- 2 files changed, 141 insertions(+), 24 deletions(-) diff --git a/crates/local-deployment/src/container.rs b/crates/local-deployment/src/container.rs index 0c99b620d46..be28726a5e1 100644 --- a/crates/local-deployment/src/container.rs +++ b/crates/local-deployment/src/container.rs @@ -42,7 +42,10 @@ use serde_json::json; use services::services::{ analytics::AnalyticsContext, approvals::{Approvals, executor_approvals::ExecutorApprovalBridge}, - cli_collab::{CliCollabService, CliExecutorDispatcher, RetryDispatchContext}, + cli_collab::{ + CliCollabService, CliExecutorDispatcher, DestructiveRetryResetAppliedError, + RetryDispatchContext, + }, config::{Config, DEFAULT_COMMIT_REMINDER_PROMPT}, container::{ContainerError, ContainerRef, ContainerService}, diff_stream::{self, DiffStreamHandle}, @@ -1208,7 +1211,7 @@ impl CliExecutorDispatcher for LocalContainerService { message: prompt.to_string(), executor_config: executor_config.clone(), }; - if let Some(retry) = retry { + let destructive_reset_completed = if let Some(retry) = retry { self.reset_session_to_process( session.id, retry.process_id, @@ -1216,15 +1219,27 @@ impl CliExecutorDispatcher for LocalContainerService { retry.force_when_dirty, ) .await?; - } - let process = self + retry.perform_git_reset + } else { + false + }; + let process = match self .start_queued_follow_up( &workspace, session, &data, retry.and_then(|retry| retry.reset_to_message_id.clone()), ) - .await?; + .await + { + Ok(process) => process, + Err(error) if destructive_reset_completed => { + return Err(anyhow::Error::new(DestructiveRetryResetAppliedError::new( + anyhow::Error::new(error), + ))); + } + Err(error) => return Err(error.into()), + }; if let Err(error) = Scratch::delete(&self.db.pool, session.id, &ScratchType::DraftFollowUp).await { @@ -1815,7 +1830,7 @@ mod tests { } #[tokio::test] - async fn local_retry_dispatch_threads_reset_id_into_follow_up_request() { + async fn local_destructive_retry_classifies_post_reset_spawn_failure() { let pool = SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") @@ -1987,21 +2002,24 @@ mod tests { let retry = RetryDispatchContext { process_id: retry_process.id, force_when_dirty: false, - perform_git_reset: false, + perform_git_reset: true, reset_to_message_id: Some(reset_to_message_id.clone()), }; + let error = CliExecutorDispatcher::dispatch( + &service, + &session, + "retry this prompt", + &missing_profile, + Some(&retry), + ) + .await + .expect_err("the missing test profile must fail after the reset"); assert!( - CliExecutorDispatcher::dispatch( - &service, - &session, - "retry this prompt", - &missing_profile, - Some(&retry), - ) - .await - .is_err(), - "the missing test profile must stop after persisting the action" + error + .downcast_ref::() + .is_some(), + "the dispatcher must preserve that the destructive reset already completed" ); let processes = ExecutionProcess::find_by_session_id(&db.pool, session.id, false) diff --git a/crates/services/src/services/cli_collab.rs b/crates/services/src/services/cli_collab.rs index 3bcc977fc7c..aff9e592de5 100644 --- a/crates/services/src/services/cli_collab.rs +++ b/crates/services/src/services/cli_collab.rs @@ -110,6 +110,22 @@ pub trait CliExecutorDispatcher: Send + Sync { ) -> AnyhowResult; } +pub const DESTRUCTIVE_RETRY_START_FAILURE_REASON: &str = + "Git reset completed, but the executor failed to start; message queued for retry"; + +#[derive(Debug, Error)] +#[error("executor start failed after destructive retry reset: {source}")] +pub struct DestructiveRetryResetAppliedError { + #[source] + source: anyhow::Error, +} + +impl DestructiveRetryResetAppliedError { + pub fn new(source: anyhow::Error) -> Self { + Self { source } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RetryDispatchContext { pub process_id: Uuid, @@ -772,13 +788,33 @@ impl CliCollabService { } Err(error) => { tracing::warn!(?error, session_id = %session.id, "executor dispatch failed; preserving prompt in queue"); + let destructive_reset_applied = error + .downcast_ref::() + .is_some(); + let failure_reason = if destructive_reset_applied { + DESTRUCTIVE_RETRY_START_FAILURE_REASON + } else { + "executor start failed; queued for retry" + }; if let Some(row) = claimed { - SessionQueuedMessage::requeue( - &self.db.pool, - row.id, - Some("executor start failed; queued for retry"), - ) - .await?; + SessionQueuedMessage::requeue(&self.db.pool, row.id, Some(failure_reason)) + .await?; + Ok(DispatchOutcome::Queued { + status: self.status(session.id).await?, + }) + } else if destructive_reset_applied { + let row = self + .store_slot( + session.id, + prompt, + executor_config, + dispatch_context, + source, + false, + ) + .await?; + SessionQueuedMessage::set_failure_reason(&self.db.pool, row.id, failure_reason) + .await?; Ok(DispatchOutcome::Queued { status: self.status(session.id).await?, }) @@ -1420,7 +1456,7 @@ pub const COLLAB_RUN_REASON: ExecutionProcessRunReason = ExecutionProcessRunReas mod tests { use std::sync::{ Mutex as StdMutex, - atomic::{AtomicUsize, Ordering}, + atomic::{AtomicBool, AtomicUsize, Ordering}, }; use db::models::{ @@ -1641,6 +1677,10 @@ mod tests { state: Arc>, } + struct ResetThenFailingDispatcher { + reset_succeeded: Arc, + } + struct SlowDispatcher { db: DBService, calls: Arc, @@ -1684,6 +1724,24 @@ mod tests { } } + #[async_trait] + impl CliExecutorDispatcher for ResetThenFailingDispatcher { + async fn dispatch( + &self, + _session: &Session, + _prompt: &str, + _executor_config: &ExecutorConfig, + retry: Option<&RetryDispatchContext>, + ) -> AnyhowResult { + let retry = retry.expect("test dispatch must be a retry"); + assert!(retry.perform_git_reset); + self.reset_succeeded.store(true, Ordering::SeqCst); + Err(anyhow::Error::new(DestructiveRetryResetAppliedError::new( + anyhow::anyhow!("spawn failed after reset"), + ))) + } + } + async fn fixture() -> (DBService, Workspace, Session) { let pool = SqlitePoolOptions::new() .max_connections(1) @@ -2488,6 +2546,47 @@ mod tests { assert_eq!(queued.prompt, "preserve failed dispatch"); } + #[tokio::test] + async fn destructive_retry_spawn_failure_reports_reset_before_queueing() { + let (db, _workspace, session) = fixture().await; + let reset_succeeded = Arc::new(AtomicBool::new(false)); + let (service, _) = service_with_components( + db, + report(false, Some(false), SidEvidence::Unknown), + Arc::new(FakeTransport), + Arc::new(ResetThenFailingDispatcher { + reset_succeeded: reset_succeeded.clone(), + }), + ); + let retry = RetryDispatchContext { + process_id: Uuid::new_v4(), + force_when_dirty: true, + perform_git_reset: true, + reset_to_message_id: Some("assistant-message".to_string()), + }; + + let outcome = service + .dispatch_retry( + &session, + "retry after reset".to_string(), + executor_config(), + QueuedMessageSource::Ui, + false, + retry, + ) + .await + .unwrap(); + + assert!(reset_succeeded.load(Ordering::SeqCst)); + let DispatchOutcome::Queued { status } = outcome else { + panic!("a post-reset spawn failure must preserve the prompt"); + }; + assert_eq!( + status.message().unwrap().failure_reason.as_deref(), + Some(DESTRUCTIVE_RETRY_START_FAILURE_REASON) + ); + } + #[tokio::test] async fn retry_conflict_never_invokes_destructive_dispatcher() { let (db, _workspace, session) = fixture().await; From 560f535dd6f35ba8145d84e886786223b045016a Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 02:08:16 +0000 Subject: [PATCH 35/36] fix(ui): ignore cancelled conflict replacement --- .../dialogs/tasks/ResolveConflictsDialog.tsx | 2 ++ .../tasks/resolveConflictsError.test.ts | 18 ++++++++++++++++++ .../dialogs/tasks/resolveConflictsError.ts | 5 +++++ 3 files changed, 25 insertions(+) create mode 100644 packages/web-core/src/shared/dialogs/tasks/resolveConflictsError.test.ts create mode 100644 packages/web-core/src/shared/dialogs/tasks/resolveConflictsError.ts diff --git a/packages/web-core/src/shared/dialogs/tasks/ResolveConflictsDialog.tsx b/packages/web-core/src/shared/dialogs/tasks/ResolveConflictsDialog.tsx index ee9035fb3a5..8e031f7e0cd 100644 --- a/packages/web-core/src/shared/dialogs/tasks/ResolveConflictsDialog.tsx +++ b/packages/web-core/src/shared/dialogs/tasks/ResolveConflictsDialog.tsx @@ -25,6 +25,7 @@ import { buildResolveConflictsInstructions } from '@/shared/lib/conflicts'; import { useExecutionProcesses } from '@/shared/hooks/useExecutionProcesses'; import { getLatestConfigFromProcesses } from '@/shared/lib/executor'; import { dispatchWithConflictResolution } from '@/shared/lib/dispatchWithConflictResolution'; +import { shouldReportResolveConflictsError } from './resolveConflictsError'; import { ConfirmDialog } from '@vibe/ui/components/ConfirmDialog'; import type { BaseCodingAgent, @@ -226,6 +227,7 @@ const ResolveConflictsDialogImpl = create( } as ResolveConflictsDialogResult); modal.hide(); } catch (err) { + if (!shouldReportResolveConflictsError(err)) return; console.error('Failed to resolve conflicts:', err); setError('Failed to start conflict resolution. Please try again.'); } finally { diff --git a/packages/web-core/src/shared/dialogs/tasks/resolveConflictsError.test.ts b/packages/web-core/src/shared/dialogs/tasks/resolveConflictsError.test.ts new file mode 100644 index 00000000000..20ddd15f250 --- /dev/null +++ b/packages/web-core/src/shared/dialogs/tasks/resolveConflictsError.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { DispatchReplacementCancelledError } from '@/shared/lib/dispatchWithConflictResolution'; +import { shouldReportResolveConflictsError } from './resolveConflictsError'; + +describe('shouldReportResolveConflictsError', () => { + it('treats an explicitly cancelled queue replacement as a no-op', () => { + expect( + shouldReportResolveConflictsError(new DispatchReplacementCancelledError()) + ).toBe(false); + }); + + it('still reports genuine conflict-resolution failures', () => { + expect( + shouldReportResolveConflictsError(new Error('backend unavailable')) + ).toBe(true); + }); +}); diff --git a/packages/web-core/src/shared/dialogs/tasks/resolveConflictsError.ts b/packages/web-core/src/shared/dialogs/tasks/resolveConflictsError.ts new file mode 100644 index 00000000000..8bf4d8ab5a7 --- /dev/null +++ b/packages/web-core/src/shared/dialogs/tasks/resolveConflictsError.ts @@ -0,0 +1,5 @@ +import { DispatchReplacementCancelledError } from '@/shared/lib/dispatchWithConflictResolution'; + +export function shouldReportResolveConflictsError(error: unknown): boolean { + return !(error instanceof DispatchReplacementCancelledError); +} From 3864881b2e822d42ca6bbbe375370997f83ba70d Mon Sep 17 00:00:00 2001 From: Miguel Rasero Date: Wed, 22 Jul 2026 02:09:54 +0000 Subject: [PATCH 36/36] test(collab): preserve recovery invariants --- .../db/src/models/session_queued_message.rs | 34 +++++++++++++++++++ crates/local-deployment/src/writer_probe.rs | 1 + crates/services/src/services/container.rs | 1 + 3 files changed, 36 insertions(+) diff --git a/crates/db/src/models/session_queued_message.rs b/crates/db/src/models/session_queued_message.rs index 3c48f1d47dc..b3d23116ec5 100644 --- a/crates/db/src/models/session_queued_message.rs +++ b/crates/db/src/models/session_queued_message.rs @@ -231,6 +231,10 @@ impl SessionQueuedMessage { executor_claim_owner: Option<&str>, paste_claim_owner: Option<&str>, ) -> Result, sqlx::Error> { + debug_assert!( + !(executor_claim_owner.is_some() && paste_claim_owner.is_some()), + "executor and paste claim owners are mutually exclusive" + ); let result = sqlx::query!( r#"UPDATE session_queued_messages SET state = 'pasting', @@ -706,6 +710,36 @@ mod tests { )); } + #[cfg(debug_assertions)] + #[tokio::test] + #[should_panic(expected = "executor and paste claim owners are mutually exclusive")] + async fn claim_rejects_simultaneous_executor_and_paste_owners_in_debug_builds() { + let (pool, session_id) = session_fixture().await; + let row = match SessionQueuedMessage::store( + &pool, + session_id, + "invalid dual claim", + None, + QueuedMessageSource::Ui, + false, + ) + .await + .unwrap() + { + StoreQueuedMessageResult::Stored(row) => row, + StoreQueuedMessageResult::Conflict(_) => unreachable!(), + }; + + let _ = SessionQueuedMessage::claim( + &pool, + row.id, + None, + Some("executor-owner"), + Some("paste-owner"), + ) + .await; + } + #[tokio::test] async fn delivery_reconciliation_recovers_pasting_but_only_hard_caps_pasted_rows() { let (pool, session_id) = session_fixture().await; diff --git a/crates/local-deployment/src/writer_probe.rs b/crates/local-deployment/src/writer_probe.rs index 7fecca387d2..2c703a6083a 100644 --- a/crates/local-deployment/src/writer_probe.rs +++ b/crates/local-deployment/src/writer_probe.rs @@ -57,6 +57,7 @@ fn resume_evidence(cmdlines: &[String]) -> SidEvidence { } } +// `_expected_sid` and `_binding` are load-bearing for `probe_path_never_replaces_live_evidence_with_matching_database_sid`. fn live_process_report( processes: &[CliPaneAgentProcess], only_active_claude_in_cwd: Option, diff --git a/crates/services/src/services/container.rs b/crates/services/src/services/container.rs index 81a051ff72a..07dfb2bd07c 100644 --- a/crates/services/src/services/container.rs +++ b/crates/services/src/services/container.rs @@ -326,6 +326,7 @@ pub trait ContainerService { // Process marked as failed tracing::info!("Marked orphaned execution process {} as failed", process.id); if let Some(cli_collab) = self.cli_collab() + // Orphan cleanup has transitioned every process here out of Running, so its collaboration writer is always releasable. && let Err(error) = cli_collab .on_executor_finished(process.session_id, ExecutionProcessStatus::Failed, true) .await