Author SHA1 Message Date
Hare acb3c6d68b fix: separate Ticket keys from assignment identity 2026-08-29 22:52:03 +09:00
Hare 40ac83e632 chore: merge develop into hare/develop 2026-08-29 13:25:32 +09:00
Hare 58da395941 fix: merge workdir registry cleanup 2026-08-29 13:21:40 +09:00
Hare 0e3ef94c9e feat: merge web console tool call presentation 2026-08-29 13:21:35 +09:00
Hare 1f68dfc2b5 fix: group grep content output by file 2026-08-28 02:05:12 +09:00
Hare 84977a464c feat: improve web console tool call presentation 2026-08-28 00:05:00 +09:00
Hare 62ada5eaa4 fix: bound safe-boundary pause escalation 2026-08-27 23:57:20 +09:00
Hare f5ff0b7c13 fix: confirm bash cancellation cleanup 2026-08-27 23:01:06 +09:00
Hare 3337cafcdf feat: own cancellable tool execution lifecycle 2026-08-27 23:01:00 +09:00
Hare e87784118b fix: preserve resumable paused tool calls 2026-08-27 21:48:59 +09:00
Hare 40fada28ea fix: preserve confirmed output on interceptor abort 2026-08-27 21:30:04 +09:00
Hare 58cc94d4b7 fix: terminalize interrupted tool executions 2026-08-27 20:54:13 +09:00
Hare ccabea59c9 fix: terminalize parallel tool outputs on completion 2026-08-27 19:16:49 +09:00
Hare 8cc1dc042d fix: remove deleted workdirs from registry 2026-08-27 18:36:17 +09:00
Hare 183c37446e fix: fence running snapshots on input commit 2026-08-27 15:42:20 +09:00
Hare 7aa06afc45 chore: preserve reviewed source lineage
# Conflicts:
#	crates/agen/README.md
#	crates/agen/examples/engine_cancel_demo.rs
#	crates/agen/examples/engine_cli.rs
#	crates/agen/src/engine.rs
#	crates/agen/tests/annotated_history_test.rs
#	crates/agen/tests/callback_test.rs
#	crates/agen/tests/engine_fixtures.rs
#	crates/agen/tests/engine_state_test.rs
#	crates/agen/tests/parallel_execution_test.rs
#	crates/agen/tests/reasoning_round_trip_test.rs
#	crates/session-store/tests/session_test.rs
#	crates/worker/src/worker.rs
2026-08-27 15:13:27 +09:00
Hare 1515a2fb86 fix: reconcile typed history with run exits 2026-08-27 15:12:05 +09:00
Hare ec798c58d7 fix: preserve annotated history through rewind 2026-08-27 14:54:24 +09:00
Hare e365189276 feat: add provenance-aware worker history 2026-08-27 14:54:24 +09:00
Hare 116d610ad0 fix: project Ticket mutation outputs to human keys 2026-08-27 14:18:48 +09:00
Hare 75c570962d Merge commit '7edc588202dfbfd4c834f677f510ddda7f3d6451' into work/00001M10HW6BV-model-facing-resource-projection 2026-08-27 13:46:19 +09:00
Hare cae8ac1799 fix: allow missing Objective query snippets 2026-08-27 13:46:10 +09:00
Hare 917cc222a3 fix: resolve relation summaries to Ticket keys 2026-08-27 13:22:08 +09:00
Hare 7edc588202 chore: refresh T-528 after T-541 2026-08-27 13:09:44 +09:00
Hare c83461508b Merge commit '21b3dd1da1b1bbf18799a0623bf67dbe6266067c' into work/00001M10HW6BV-model-facing-resource-projection 2026-08-27 13:09:15 +09:00
Hare 651d64f34d Merge branch 'work/00001M10N3Z0H-ticket-detail-route-sync' into hare/develop 2026-08-27 12:57:52 +09:00
Hare b98d4b59f5 fix: synchronize reused ticket detail routes 2026-08-27 12:55:01 +09:00
Hare 374449e663 feat: add provenance-aware worker history 2026-08-27 12:51:01 +09:00
Hare 4c876a201b fix: validate projected resource keys canonically 2026-08-27 12:49:59 +09:00
Hare 21b3dd1da1 Merge commit '0496cd907bc7bb96e9aa1c6d385bedb616bf3233' into work/00001M10FJVA2-orchestrator-queue-notice 2026-08-27 12:46:27 +09:00
Hare 5ca0ea9228 fix: sanitize orchestrator queue attention 2026-08-27 12:46:10 +09:00
Hare d5c3a68a37 fix: use Ticket resource keys in handoffs 2026-08-27 12:45:08 +09:00
Hare 2b33b9158d chore: refresh T-528 against develop 2026-08-27 12:45:01 +09:00
Hare b31642e284 feat: project Ticket and Objective human references 2026-08-27 12:39:28 +09:00
Hare 3a7a3307ef fix: map internal worker terminal lifecycles 2026-08-27 12:27:55 +09:00
Hare 0496cd907b fix: use Ticket resource keys in handoffs 2026-08-27 12:24:19 +09:00
Hare 975b4fa700 feat: add typed engine run exits 2026-08-27 11:42:49 +09:00
Hare 060f280fdf fix: allow parent commands during write delegation 2026-08-27 11:27:27 +09:00
Hare 7aaf189247 fix: scope delegated commands by cwd 2026-08-27 11:11:06 +09:00
Hare df6d99c07d Merge branch 'develop' into hare/develop 2026-08-27 09:21:58 +09:00
Hare 63306cf017 feat: integrate runtime Git cache materialization 2026-08-27 08:30:38 +09:00
Hare 08be5e85e4 fix: preserve command sessions across access refresh 2026-08-27 08:09:22 +09:00
Hare 9843510e1f fix: remove conflicting coder worker control provider 2026-08-26 23:52:25 +09:00
Hare 83bda3dfb2 fix: restore companion subworker control 2026-08-26 22:06:34 +09:00
Hare 0ab15aa227 chore: merge hare/develop into develop 2026-08-26 19:44:31 +09:00
Hare 29c2fb8e06 fix: refresh repository access for worker workdirs 2026-08-26 19:27:10 +09:00
Hare 4ebc465e8d fix: constrain repository SSH commands to authorized source 2026-08-26 19:02:20 +09:00
Hare 4b132a21e9 fix: bind repository SSH secrets to one-shot resources 2026-08-26 18:28:22 +09:00
Hare b644971d45 fix: preauthorize repository access without persisting secrets 2026-08-26 17:23:43 +09:00
Hare df34533765 fix: broker read-only repository SSH operations 2026-08-26 16:48:37 +09:00
Hare 6f4efb36bb fix: order optional feature service bindings 2026-08-26 16:28:54 +09:00
Hare 108d5b14d7 fix: withhold write-capable credentials from read-only sessions 2026-08-26 16:23:48 +09:00
Hare f633b86b35 chore: integrate current develop for review 2026-08-26 16:12:48 +09:00
Hare 1873e18f8e fix: enforce attachment access transitions 2026-08-26 16:10:39 +09:00
Hare 3cdcbb47bf fix: preserve selector retries and workdir observations 2026-08-26 16:05:14 +09:00
Hare aaa9c7987c fix: reject ambiguous feature service providers 2026-08-26 16:01:06 +09:00
Hare 048007a042 test: complete tool output fixture 2026-08-26 15:47:34 +09:00
Hare cb35b40b9d feat: plan feature service installation 2026-08-26 15:47:27 +09:00
Hare 3b71fe03b4 fix: include merge request status regression in web tests 2026-08-26 15:27:19 +09:00
Hare 471db64bcc fix: preserve repository access across retries 2026-08-26 15:21:52 +09:00
Hare 5e2234763c fix: distinguish completed review requests in merge request status 2026-08-26 15:20:13 +09:00
Hare 1d140be715 feat: align merge request review workflow with selector refs 2026-08-26 15:03:26 +09:00
Hare ffb2a34ae5 fix: enforce repository access and cache boundaries 2026-08-26 14:39:37 +09:00
Hare ccf7de1a55 fix: preserve run budget across segment forks 2026-08-26 14:13:43 +09:00
Hare ccf3c80d29 fix: clear abandoned run budget before compaction 2026-08-26 14:03:07 +09:00
Hare 3a3c89e0b4 feat: materialize repositories through runtime Git cache 2026-08-26 13:54:25 +09:00
Hare 17c629136a fix: scope max turns to logical runs 2026-08-26 13:47:35 +09:00
Hare 52a5c4141f chore: merge current develop into Ticket source 2026-08-26 12:24:14 +09:00
Hare c9ba27c333 fix: update RepositoryRecord test URI access 2026-08-26 11:50:41 +09:00
Hare 46f6e2c58b fix: complete Repository access management contracts 2026-08-26 11:38:23 +09:00
Hare d1f47e5a22 fix: align Repository access DCDL fields 2026-08-26 11:06:19 +09:00
Hare 65de94bad3 feat: expose compaction as an internal worker 2026-08-26 10:39:43 +09:00
Hare a9fb092834 feat: add Repository access settings UI 2026-08-26 08:51:23 +09:00
Hare 3a21a68792 feat: add Workspace Repository SSH secret authority 2026-08-26 08:51:15 +09:00
148 changed files with 20325 additions and 2823 deletions
Generated
+521 -1
View File
@@ -26,6 +26,16 @@ dependencies = [
"pom",
]
[[package]]
name = "aead"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common 0.1.7",
"generic-array",
]
[[package]]
name = "aes"
version = "0.8.4"
@@ -37,6 +47,20 @@ dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]]
name = "agen"
version = "0.2.1"
@@ -326,6 +350,12 @@ dependencies = [
"tracing",
]
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.21.7"
@@ -338,6 +368,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "base64urlsafedata"
version = "0.5.5"
@@ -349,6 +385,17 @@ dependencies = [
"serde",
]
[[package]]
name = "bcrypt-pbkdf"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2"
dependencies = [
"blowfish",
"pbkdf2",
"sha2 0.10.9",
]
[[package]]
name = "bit-set"
version = "0.5.3"
@@ -403,6 +450,16 @@ dependencies = [
"generic-array",
]
[[package]]
name = "blowfish"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
dependencies = [
"byteorder",
"cipher",
]
[[package]]
name = "bstr"
version = "1.12.1"
@@ -435,6 +492,12 @@ version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -495,6 +558,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chacha20"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures 0.2.17",
]
[[package]]
name = "chrono"
version = "0.4.44"
@@ -654,6 +728,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
@@ -937,6 +1017,18 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -966,6 +1058,41 @@ dependencies = [
"phf 0.11.3",
]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto",
"rustc_version",
"subtle",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "darling"
version = "0.23.0"
@@ -1056,6 +1183,16 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4"
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
"zeroize",
]
[[package]]
name = "der-parser"
version = "9.0.0"
@@ -1114,7 +1251,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer 0.10.4",
"const-oid 0.9.6",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
@@ -1124,7 +1263,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
dependencies = [
"block-buffer 0.12.0",
"const-oid",
"const-oid 0.10.2",
"crypto-common 0.2.1",
]
@@ -1175,12 +1314,66 @@ dependencies = [
"cipher",
]
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"sha2 0.10.9",
"subtle",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest 0.10.7",
"ff",
"generic-array",
"group",
"pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
]
[[package]]
name = "embedded-io"
version = "0.4.0"
@@ -1284,6 +1477,22 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -1526,6 +1735,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@@ -1568,6 +1778,16 @@ dependencies = [
"wasip3",
]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]]
name = "gimli"
version = "0.33.0"
@@ -1636,6 +1856,17 @@ dependencies = [
"memmap2",
]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "h2"
version = "0.4.13"
@@ -1724,6 +1955,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "html5ever"
version = "0.26.0"
@@ -2212,6 +2452,9 @@ name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
dependencies = [
"spin",
]
[[package]]
name = "leb128fmt"
@@ -2672,6 +2915,22 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
dependencies = [
"lazy_static",
"libm",
"num-integer",
"num-iter",
"num-traits",
"rand 0.8.5",
"smallvec",
"zeroize",
]
[[package]]
name = "num-conv"
version = "0.2.1"
@@ -2698,6 +2957,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2705,6 +2974,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
"libm",
]
[[package]]
@@ -2765,6 +3035,12 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "openssl"
version = "0.10.76"
@@ -2818,6 +3094,44 @@ dependencies = [
"num-traits",
]
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2 0.10.9",
]
[[package]]
name = "p384"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2 0.10.9",
]
[[package]]
name = "p521"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2"
dependencies = [
"base16ct",
"ecdsa",
"elliptic-curve",
"primeorder",
"rand_core 0.6.4",
"sha2 0.10.9",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
@@ -2847,6 +3161,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest 0.10.7",
]
[[package]]
name = "pdf-extract"
version = "0.10.0"
@@ -2864,6 +3187,15 @@ dependencies = [
"unicode-normalization",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -3009,6 +3341,27 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs1"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
"der",
"pkcs8",
"spki",
]
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -3021,6 +3374,29 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "poly1305"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
[[package]]
name = "pom"
version = "1.1.0"
@@ -3101,6 +3477,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -3525,6 +3910,16 @@ dependencies = [
"web-sys",
]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac",
"subtle",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -3539,6 +3934,27 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rsa"
version = "0.9.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
dependencies = [
"const-oid 0.9.6",
"digest 0.10.7",
"num-bigint-dig",
"num-integer",
"num-traits",
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"sha2 0.10.9",
"signature",
"spki",
"subtle",
"zeroize",
]
[[package]]
name = "rusqlite"
version = "0.37.0"
@@ -3745,6 +4161,20 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]]
name = "secrets"
version = "0.1.0"
@@ -4060,6 +4490,16 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest 0.10.7",
"rand_core 0.6.4",
]
[[package]]
name = "simd-adler32"
version = "0.3.9"
@@ -4103,6 +4543,72 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spin"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "ssh-cipher"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
dependencies = [
"aes",
"aes-gcm",
"cbc",
"chacha20",
"cipher",
"ctr",
"poly1305",
"ssh-encoding",
"subtle",
]
[[package]]
name = "ssh-encoding"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15"
dependencies = [
"base64ct",
"pem-rfc7468",
"sha2 0.10.9",
]
[[package]]
name = "ssh-key"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
dependencies = [
"bcrypt-pbkdf",
"ed25519-dalek",
"p256",
"p384",
"p521",
"rand_core 0.6.4",
"rsa",
"sec1",
"sha2 0.10.9",
"signature",
"ssh-cipher",
"ssh-encoding",
"subtle",
"zeroize",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -4934,6 +5440,16 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "universal-hash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
@@ -6136,6 +6652,7 @@ dependencies = [
"workdir",
"worker",
"workspace-api",
"zeroize",
]
[[package]]
@@ -6259,11 +6776,13 @@ dependencies = [
"project-record",
"protocol",
"reqwest",
"ring",
"rusqlite",
"serde",
"serde_json",
"serde_yaml",
"sha2 0.11.0",
"ssh-key",
"tempfile",
"thiserror 2.0.18",
"ticket",
@@ -6280,6 +6799,7 @@ dependencies = [
"worker",
"worker-runtime",
"workspace-api",
"zeroize",
]
[[package]]
+2
View File
@@ -115,6 +115,7 @@ tar = "0.4"
rusqlite = { version = "0.37", features = ["backup", "bundled"] }
ring = "0.17.14"
sha2 = "0.11"
ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
tempfile = "3.27"
thiserror = "2.0"
tokio = "1.52"
@@ -124,4 +125,5 @@ toml = "1.1"
tracing = "0.1"
url = "2.5"
uuid = "1.23"
zeroize = "1"
webauthn-rs = { version = "0.5.2", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
+6 -5
View File
@@ -21,20 +21,21 @@ agen = { version = "0.2.1", features = ["codex"] }
## Quick start
Supply an implementation of [`LlmClient`](https://docs.rs/agen/latest/agen/llm_client/trait.LlmClient.html), then run a turn. The first call consumes the mutable engine and returns a cache-locked engine for later turns.
Supply an implementation of [`LlmClient`](https://docs.rs/agen/latest/agen/llm_client/trait.LlmClient.html), keep conversation history in your application, then run a turn. The first call consumes the mutable engine and returns a cache-locked engine for later turns.
```no_run
use agen::{Engine, EngineError};
use agen::{Engine, EngineError, History};
use agen::llm_client::LlmClient;
async fn conversation<C: LlmClient>(client: C) -> Result<(), EngineError> {
let mut history = History::new();
let output = Engine::new(client)
.system_prompt("You are a concise assistant.")
.run("Explain typed state in one sentence.")
.await?;
.run(&mut history, "Explain typed state in one sentence.")
.await;
let mut engine = output.engine;
let _result = engine.run("Give a Rust example.").await?;
let _result = engine.run(&mut history, "Give a Rust example.").await;
Ok(())
}
```
+10 -10
View File
@@ -4,7 +4,7 @@
use agen::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
use agen::llm_client::transport::{HttpTransport, ResolvedAuth};
use agen::{Engine, EngineResult};
use agen::{Engine, EngineRunExit, StopReason};
use std::time::Duration;
#[tokio::main]
@@ -29,6 +29,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let base_url = scheme.default_base_url().to_string();
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
let engine = Engine::new(client);
let mut history = agen::History::new();
println!("🚀 Starting Engine...");
println!("💡 Will cancel after 2 seconds\n");
@@ -45,16 +46,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("📡 Sending request to LLM...");
match engine.run("Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await {
Ok(out) => match out.result {
EngineResult::Finished => println!("✅ Task completed normally"),
EngineResult::Paused => println!("⏸️ Task paused"),
EngineResult::LimitReached => println!("🔒 Turn limit reached"),
EngineResult::Yielded => println!("↩️ Task yielded"),
},
Err(e) => {
println!("❌ Task error: {}", e);
let output = engine.run(&mut history, "Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await;
match output.result {
EngineRunExit::Finished => println!("✅ Task completed normally"),
EngineRunExit::Paused => println!("⏸️ Task paused"),
EngineRunExit::Yielded => println!("↩️ Task yielded"),
EngineRunExit::Interrupted(StopReason::LimitReached) => {
println!("🔒 Turn limit reached")
}
EngineRunExit::Interrupted(reason) => println!("❌ Task interrupted: {reason:?}"),
}
println!("\n✨ Demo complete!");
+11 -19
View File
@@ -39,7 +39,7 @@ use tracing::info;
use tracing_subscriber::EnvFilter;
use agen::{
Engine,
Engine, EngineRunExit, StopReason,
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
llm_client::{
LlmClient,
@@ -451,6 +451,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Create Engine
let mut engine = Engine::new(client);
let mut history = agen::History::new();
let tool_call_names = Arc::new(Mutex::new(HashMap::new()));
@@ -476,12 +477,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// One-shot mode
if let Some(prompt) = args.prompt {
match engine.run(&prompt).await {
Ok(_) => {}
Err(e) => {
eprintln!("\n❌ Error: {}", e);
std::process::exit(1);
}
let output = engine.run(&mut history, &prompt).await;
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) = output.result {
eprintln!("\n❌ Error: {error}");
}
return Ok(());
@@ -500,13 +498,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
return Ok(());
}
let mut locked = match engine.run(first_input).await {
Ok(out) => out.engine,
Err(e) => {
eprintln!("\n❌ Error: {}", e);
return Ok(());
}
};
let output = engine.run(&mut history, first_input).await;
let mut locked = output.engine;
loop {
print!("\n👤 You: ");
@@ -525,11 +518,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
break;
}
match locked.run(input).await {
Ok(_) => {}
Err(e) => {
eprintln!("\n❌ Error: {}", e);
}
if let EngineRunExit::Interrupted(StopReason::Unexpected(error)) =
locked.run(&mut history, input).await
{
eprintln!("\n❌ Error: {error}");
}
}
+886 -283
View File
File diff suppressed because it is too large Load Diff
+199
View File
@@ -0,0 +1,199 @@
//! Typed conversation history containers.
//!
//! Agen keeps provider-visible [`Item`](crate::Item) values separate from any
//! host-domain provenance. The host chooses the annotation type `A`, while Agen
//! preserves each item and annotation as one entry for clone/truncate/restore
//! style history operations.
use serde::{Deserialize, Serialize};
use crate::Item;
/// One conversation-history entry with host-owned annotation.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HistoryEntry<A = ()> {
/// Provider/model-visible conversation item.
pub item: Item,
/// Host-domain metadata kept with the item and never projected to providers.
pub annotation: A,
}
impl<A> HistoryEntry<A> {
/// Build an entry from an item and its annotation.
pub fn new(item: Item, annotation: A) -> Self {
Self { item, annotation }
}
/// Split the entry into its item and annotation.
pub fn into_parts(self) -> (Item, A) {
(self.item, self.annotation)
}
}
impl HistoryEntry<()> {
/// Build a unit-annotated entry.
pub fn from_item(item: Item) -> Self {
Self {
item,
annotation: (),
}
}
}
/// Conversation history with one annotation per item.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct History<A = ()> {
entries: Vec<HistoryEntry<A>>,
}
impl<A> History<A> {
/// Create an empty history.
pub fn new() -> Self {
Self {
entries: Vec::new(),
}
}
/// Build history from already annotated entries, preserving order.
pub fn from_entries(entries: Vec<HistoryEntry<A>>) -> Self {
Self { entries }
}
/// Replace all entries as one restore/rebuild operation and return the old entries.
pub fn replace_entries(&mut self, entries: Vec<HistoryEntry<A>>) -> Vec<HistoryEntry<A>> {
std::mem::replace(&mut self.entries, entries)
}
/// Borrow annotated entries.
pub fn entries(&self) -> &[HistoryEntry<A>] {
&self.entries
}
/// Mutably borrow annotated entries for host-owned rebuild operations.
pub fn entries_mut(&mut self) -> &mut [HistoryEntry<A>] {
&mut self.entries
}
/// Consume the history into annotated entries.
pub fn into_entries(self) -> Vec<HistoryEntry<A>> {
self.entries
}
/// Number of entries.
pub fn len(&self) -> usize {
self.entries.len()
}
/// Whether the history is empty.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// Iterate over annotated entries.
pub fn iter(&self) -> impl ExactSizeIterator<Item = &HistoryEntry<A>> {
self.entries.iter()
}
/// Iterate over provider-visible items only.
pub fn items(&self) -> impl ExactSizeIterator<Item = &Item> {
self.entries.iter().map(|entry| &entry.item)
}
/// Clone provider-visible items into a request-local projection.
pub fn items_cloned(&self) -> Vec<Item> {
self.items().cloned().collect()
}
/// Append an already annotated entry.
pub fn push_entry(&mut self, entry: HistoryEntry<A>) {
self.entries.push(entry);
}
/// Append many already annotated entries.
pub fn extend_entries(&mut self, entries: impl IntoIterator<Item = HistoryEntry<A>>) {
self.entries.extend(entries);
}
/// Commit one item through a trusted annotation callback before it becomes live.
///
/// The callback may durably persist the item and returns the annotation that
/// must be stored with it. If the callback fails, the history is left unchanged.
pub fn append_with(
&mut self,
item: Item,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<(), String> {
let annotation = annotate(&item)?;
self.entries.push(HistoryEntry { item, annotation });
Ok(())
}
/// Commit items through a trusted annotation callback before they become live.
///
/// Items before a failure remain appended; the failing item and later items do
/// not enter history. This mirrors append-only durable logs where each accepted
/// item is already committed before the next item is attempted.
pub fn extend_with(
&mut self,
items: impl IntoIterator<Item = Item>,
annotate: &mut impl FnMut(&Item) -> Result<A, String>,
) -> Result<(), String> {
for item in items {
self.append_with(item, annotate)?;
}
Ok(())
}
/// Truncate entries, preserving item+annotation pairing for retained entries.
pub fn truncate(&mut self, len: usize) {
self.entries.truncate(len);
}
/// Clear all entries.
pub fn clear(&mut self) {
self.entries.clear();
}
}
impl History<()> {
/// Build unit-annotated history from provider-visible items.
pub fn from_items(items: Vec<Item>) -> Self {
Self {
entries: items.into_iter().map(HistoryEntry::from_item).collect(),
}
}
/// Replace history from provider-visible items using unit annotations.
pub fn replace_items(&mut self, items: Vec<Item>) -> Vec<HistoryEntry<()>> {
self.replace_entries(items.into_iter().map(HistoryEntry::from_item).collect())
}
/// Append one item with unit annotation.
pub fn push(&mut self, item: Item) {
self.entries.push(HistoryEntry::from_item(item));
}
/// Append items with unit annotations.
pub fn extend_items(&mut self, items: impl IntoIterator<Item = Item>) {
self.entries
.extend(items.into_iter().map(HistoryEntry::from_item));
}
}
impl<A> IntoIterator for History<A> {
type Item = HistoryEntry<A>;
type IntoIter = std::vec::IntoIter<HistoryEntry<A>>;
fn into_iter(self) -> Self::IntoIter {
self.entries.into_iter()
}
}
impl<'a, A> IntoIterator for &'a History<A> {
type Item = &'a HistoryEntry<A>;
type IntoIter = std::slice::Iter<'a, HistoryEntry<A>>;
fn into_iter(self) -> Self::IntoIter {
self.entries.iter()
}
}
+9 -3
View File
@@ -2,6 +2,7 @@
mod engine;
mod handler;
mod history;
mod message;
pub(crate) mod callback;
@@ -20,13 +21,18 @@ pub mod usage_record;
pub use agen_macros::{description, tool, tool_registry};
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
pub use engine::{
Engine, EngineConfig, EngineError, EngineResult, EngineRunOutput, LlmRetryNotice,
ToolRegistryError,
Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
LlmRetryNotice, StopReason, ToolRegistryError,
};
pub use handler::ToolUseBlockStart;
pub use history::{History, HistoryEntry};
pub use interceptor::Interceptor;
pub use message::{ContentPart, Item, Message, Role};
pub use tool::{ToolCall, ToolExecutionContext, ToolOutputLimits, ToolResult};
pub use tool::{
ToolCall, ToolExecutionContext, ToolExecutionHandle, ToolExecutionPolicy,
ToolExecutionTerminal, ToolExecutionTerminalFuture, ToolOutputLimits, ToolResult,
ToolResultDisposition,
};
pub use usage_record::UsageRecord;
/// Implementation dependencies used by code generated from `agen` macros.
+8 -1
View File
@@ -18,6 +18,9 @@ pub enum ClientError {
message: String,
retry_after: Option<Duration>,
},
/// The provider rejected the request because it exceeded the model context window.
/// Classified only from a structured provider error code, never message text.
ContextWindowExceeded,
/// A request lifecycle phase exceeded its hard timeout.
Timeout {
phase: &'static str,
@@ -48,6 +51,7 @@ impl fmt::Display for ClientError {
}
write!(f, ": {}", message)
}
ClientError::ContextWindowExceeded => write!(f, "Model context window reached"),
ClientError::Timeout { phase, timeout } => {
write!(f, "{phase} timed out after {}s", timeout.as_secs())
}
@@ -112,7 +116,10 @@ pub fn is_retryable(error: &ClientError) -> bool {
ClientError::Api { status: None, .. } => false,
ClientError::Timeout { .. } => true,
ClientError::Http(e) => e.is_connect() || e.is_timeout(),
ClientError::Json(_) | ClientError::Sse(_) | ClientError::Config(_) => false,
ClientError::ContextWindowExceeded
| ClientError::Json(_)
| ClientError::Sse(_)
| ClientError::Config(_) => false,
}
}
+4 -7
View File
@@ -431,13 +431,7 @@ fn api_error_code(error: &ClientError) -> Option<&str> {
}
fn is_context_length_exceeded(error: &ClientError) -> bool {
match error {
ClientError::Api { code, message, .. } => {
code.as_deref() == Some("context_length_exceeded")
|| message.contains("context_length_exceeded")
}
_ => false,
}
matches!(error, ClientError::ContextWindowExceeded)
}
async fn response_with_timeout(
@@ -487,6 +481,9 @@ async fn classify_error_response(resp: reqwest::Response) -> ClientError {
.and_then(|v| v.as_str())
.unwrap_or(&text)
.to_string();
if code.as_deref() == Some("context_length_exceeded") {
return ClientError::ContextWindowExceeded;
}
ClientError::Api {
status: Some(status),
code,
+37 -2
View File
@@ -9,7 +9,7 @@
use std::{fmt, sync::Arc};
use crate::tool::Attachment;
use crate::tool::{Attachment, ToolResultDisposition};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
@@ -121,6 +121,9 @@ pub enum Item {
/// Detailed output (removed by pruning when old enough)
#[serde(default, skip_serializing_if = "Option::is_none")]
content: Option<String>,
/// Typed terminal state used for replay and recovery.
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
disposition: ToolResultDisposition,
/// Whether the tool result represents an execution error.
#[serde(default, skip_serializing_if = "is_false")]
is_error: bool,
@@ -261,7 +264,17 @@ impl Item {
content: Option<String>,
is_error: bool,
) -> Self {
Self::tool_result_item_with_attachments(call_id, summary, content, is_error, Vec::new())
Self::tool_result_item_with_disposition_and_attachments(
call_id,
summary,
content,
if is_error {
ToolResultDisposition::Error
} else {
ToolResultDisposition::Success
},
Vec::new(),
)
}
/// Create a tool result item with durable, prunable structured attachments.
@@ -272,11 +285,33 @@ impl Item {
is_error: bool,
attachments: Vec<Attachment>,
) -> Self {
Self::tool_result_item_with_disposition_and_attachments(
call_id,
summary,
content,
if is_error {
ToolResultDisposition::Error
} else {
ToolResultDisposition::Success
},
attachments,
)
}
pub fn tool_result_item_with_disposition_and_attachments(
call_id: impl Into<String>,
summary: impl Into<String>,
content: Option<String>,
disposition: ToolResultDisposition,
attachments: Vec<Attachment>,
) -> Self {
let is_error = !disposition.is_success();
Self::ToolResult {
id: None,
call_id: call_id.into(),
summary: summary.into(),
content,
disposition,
is_error,
attachments,
}
+1 -1
View File
@@ -19,7 +19,7 @@ mod private {
/// - Editing message history (add, delete, clear)
/// - Registering tools and hooks
///
/// Can transition to [`Locked`] state via `Engine::lock()`.
/// Can transition to [`Locked`] state via `Engine::lock(&history)`.
///
/// # Examples
///
+227 -2
View File
@@ -3,7 +3,14 @@
//! Traits for defining tools callable by LLM.
//! Usually auto-implemented using the `#[tool]` macro.
use std::{collections::HashMap, fmt, sync::Arc};
use std::{
collections::HashMap,
fmt,
future::Future,
pin::Pin,
sync::Arc,
task::{Context, Poll},
};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::STANDARD};
@@ -23,6 +30,12 @@ pub enum ToolError {
/// Internal error
#[error("Internal error: {0}")]
Internal(String),
/// Cooperative cancellation completed with bounded terminal output.
#[error("Tool execution cancelled")]
Cancelled(ToolOutput),
/// Execution was interrupted with a confirmed bounded terminal output.
#[error("Tool execution interrupted")]
Interrupted(ToolOutput),
}
// =============================================================================
@@ -158,6 +171,28 @@ pub enum Attachment {
Image(ImageAttachment),
}
/// Terminal disposition of one started tool call.
///
/// `Cancelled` means the tool confirmed cancellation. `OutcomeUnknown` means
/// execution stopped without confirmation, so neither completion nor side
/// effects may be inferred.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ToolResultDisposition {
#[default]
Success,
Error,
Interrupted,
Cancelled,
OutcomeUnknown,
}
impl ToolResultDisposition {
pub const fn is_success(&self) -> bool {
matches!(self, Self::Success)
}
}
/// Tool execution result.
///
/// Every output has a mandatory `summary` (1-2 lines) that persists in
@@ -322,6 +357,12 @@ impl ToolExecutionContext {
}
}
/// Identifies one live execution attempt without making the batch id a durable
/// replay or idempotency authority.
pub fn execution_id(&self) -> String {
format!("{}:{}", self.batch_id, self.call_id)
}
/// Context for direct, non-engine calls in unit tests and low-level callers.
pub fn direct() -> Self {
Self::new("direct", "direct", 0)
@@ -334,6 +375,142 @@ impl Default for ToolExecutionContext {
}
}
/// The provider-confirmed terminal result of one started tool execution.
///
/// `OutcomeUnknown` is reserved for an execution task that had to be force-closed
/// or failed before the provider could confirm its terminal result.
#[derive(Debug)]
pub enum ToolExecutionTerminal {
Confirmed(Result<ToolOutput, ToolError>),
OutcomeUnknown,
}
/// The completion future paired with a [`ToolExecutionHandle`]. Dropping this
/// future does not drop the provider execution: the spawned execution remains
/// owned by its handle until it completes or is explicitly force-closed.
pub struct ToolExecutionTerminalFuture {
task: tokio::task::JoinHandle<Result<ToolOutput, ToolError>>,
}
impl Future for ToolExecutionTerminalFuture {
type Output = ToolExecutionTerminal;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
match Pin::new(&mut self.task).poll(cx) {
Poll::Ready(Ok(result)) => Poll::Ready(ToolExecutionTerminal::Confirmed(result)),
Poll::Ready(Err(_)) => Poll::Ready(ToolExecutionTerminal::OutcomeUnknown),
Poll::Pending => Poll::Pending,
}
}
}
/// Live ownership and control for one started tool execution.
///
/// Execution, cancellation, and terminal confirmation remain provider-owned:
/// this handle starts `Tool::execute`, delegates cooperative cancellation to
/// `Tool::cancel_execution`, and treats execution-future completion as the
/// provider's terminal confirmation. Agen may force-close only after its caller's
/// deadline expires, at which point the outcome is necessarily unknown.
#[derive(Clone)]
pub struct ToolExecutionHandle {
inner: Arc<ToolExecutionHandleInner>,
}
struct ToolExecutionHandleInner {
tool: Arc<dyn Tool>,
context: ToolExecutionContext,
abort: tokio::task::AbortHandle,
}
impl Drop for ToolExecutionHandleInner {
fn drop(&mut self) {
// Losing the final live owner is an explicit forced close, never a
// best-effort detached provider future.
self.abort.abort();
}
}
impl fmt::Debug for ToolExecutionHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ToolExecutionHandle")
.field("call_id", &self.inner.context.call_id)
.field("batch_id", &self.inner.context.batch_id)
.finish_non_exhaustive()
}
}
impl ToolExecutionHandle {
pub fn start(
tool: Arc<dyn Tool>,
input_json: String,
context: ToolExecutionContext,
) -> (Self, ToolExecutionTerminalFuture) {
let execution_tool = Arc::clone(&tool);
let execution_context = context.clone();
let task =
tokio::spawn(
async move { execution_tool.execute(&input_json, execution_context).await },
);
let abort = task.abort_handle();
(
Self {
inner: Arc::new(ToolExecutionHandleInner {
tool,
context,
abort,
}),
},
ToolExecutionTerminalFuture { task },
)
}
pub fn context(&self) -> &ToolExecutionContext {
&self.inner.context
}
pub async fn cancel_before(&self, deadline: tokio::time::Instant) -> Result<(), ToolError> {
match tokio::time::timeout_at(
deadline,
self.inner.tool.cancel_execution(&self.inner.context),
)
.await
{
Ok(result) => result,
Err(_) => Err(ToolError::Internal(format!(
"tool cancellation request exceeded its deadline for call {}",
self.inner.context.call_id
))),
}
}
pub fn force_close(&self) {
self.inner.abort.abort();
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ToolExecutionPolicy {
/// Time a pause waits for already-started providers to reach a natural safe
/// boundary before escalating to explicit cooperative cancellation.
pub pause_safe_boundary_timeout: std::time::Duration,
/// Maximum time allowed for a provider to accept one cooperative
/// cancellation request.
pub cancellation_request_timeout: std::time::Duration,
/// Maximum time allowed for all providers to confirm terminal results after
/// cancellation has been requested.
pub terminal_confirmation_timeout: std::time::Duration,
}
impl Default for ToolExecutionPolicy {
fn default() -> Self {
Self {
pause_safe_boundary_timeout: std::time::Duration::from_millis(100),
cancellation_request_timeout: std::time::Duration::from_millis(100),
terminal_confirmation_timeout: std::time::Duration::from_millis(500),
}
}
}
// =============================================================================
// Tool trait
// =============================================================================
@@ -402,6 +579,26 @@ pub trait Tool: Send + Sync {
input_json: &str,
ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError>;
/// Request cooperative cancellation for one started call.
///
/// Implementations that own cancellable provider operations should signal
/// every live execution identified by `call_id`, then let `execute` return
/// the confirmed bounded terminal output. Direct callers may use this
/// compatibility surface; Agen uses [`Tool::cancel_execution`] so providers
/// can bind cancellation to one exact live attempt.
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
Ok(())
}
/// Request cooperative cancellation for one exact started execution.
///
/// The default preserves existing tools by delegating to `cancel(call_id)`.
/// Providers with their own execution registry should override this method
/// and key cancellation by [`ToolExecutionContext::execution_id`].
async fn cancel_execution(&self, ctx: &ToolExecutionContext) -> Result<(), ToolError> {
self.cancel(&ctx.call_id).await
}
}
// =============================================================================
@@ -429,6 +626,9 @@ pub struct ToolCall {
pub struct ToolResult {
/// Corresponding tool call ID
pub tool_use_id: String,
/// Typed terminal state.
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
pub disposition: ToolResultDisposition,
/// Short summary (always kept in history)
pub summary: String,
/// Detailed output (prunable)
@@ -445,11 +645,20 @@ pub struct ToolResult {
impl ToolResult {
/// Create a success result from a [`ToolOutput`].
pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
Self::from_output_with_disposition(tool_use_id, output, ToolResultDisposition::Success)
}
pub fn from_output_with_disposition(
tool_use_id: impl Into<String>,
output: ToolOutput,
disposition: ToolResultDisposition,
) -> Self {
Self {
tool_use_id: tool_use_id.into(),
disposition,
summary: output.summary,
content: output.content,
is_error: false,
is_error: !disposition.is_success(),
attachments: output.attachments,
}
}
@@ -458,12 +667,28 @@ impl ToolResult {
pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self {
Self {
tool_use_id: tool_use_id.into(),
disposition: ToolResultDisposition::Error,
summary: message.into(),
content: None,
is_error: true,
attachments: Vec::new(),
}
}
/// Close an execution whose completion and side effects cannot be confirmed.
pub fn outcome_unknown(tool_use_id: impl Into<String>) -> Self {
Self {
tool_use_id: tool_use_id.into(),
disposition: ToolResultDisposition::OutcomeUnknown,
summary: "Tool execution outcome unknown".to_string(),
content: Some(
"Execution was interrupted before completion could be confirmed. Completion and side effects are unknown."
.to_string(),
),
is_error: true,
attachments: Vec::new(),
}
}
}
#[cfg(test)]
@@ -0,0 +1,84 @@
mod common;
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::{Engine, EngineError, History, HistoryEntry, Item, Role};
use common::MockLlmClient;
fn completed_text_events(text: &str) -> Vec<Event> {
vec![
Event::text_block_start(0),
Event::text_delta(0, text),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
}
#[tokio::test]
async fn run_preserves_item_annotations_without_projecting_them() {
let client = MockLlmClient::new(completed_text_events("assistant reply"));
let engine = Engine::<_, agen::state::Mutable, String>::new_annotated(client);
let mut history = History::<String>::new();
let mut next = 0usize;
let mut annotate = |item: &Item| {
next += 1;
let kind = match item {
Item::Message { role, .. } => match role {
Role::User => "user",
Role::Assistant => "assistant",
Role::System => "system",
},
Item::ToolCall { .. } => "tool_call",
Item::ToolResult { .. } => "tool_result",
Item::Reasoning { .. } => "reasoning",
};
Ok(format!("{next}:{kind}"))
};
let output = engine
.run_with_annotation(&mut history, "hello", &mut annotate)
.await;
assert!(matches!(output.result, agen::EngineRunExit::Finished));
assert_eq!(history.len(), 2);
assert_eq!(history.entries()[0].annotation, "1:user");
assert_eq!(history.entries()[1].annotation, "2:assistant");
assert_eq!(history.items_cloned().len(), 2);
}
#[test]
fn append_failure_does_not_make_item_live() {
let client = MockLlmClient::new(vec![]);
let mut engine = Engine::<_, agen::state::Mutable, usize>::new_annotated(client);
let mut history = History::<usize>::new();
let mut fail = |_item: &Item| Err("commit failed".to_string());
let err = engine
.append_history_with(&mut history, [Item::user_message("uncommitted")], &mut fail)
.unwrap_err();
assert!(matches!(err, EngineError::HistoryAppend(message) if message == "commit failed"));
assert!(history.is_empty());
}
#[test]
fn replacement_keeps_items_and_annotations_together() {
let mut history = History::from_entries(vec![
HistoryEntry::new(Item::user_message("old"), "old-ann".to_string()),
HistoryEntry::new(Item::user_message("second"), "second-ann".to_string()),
]);
history.truncate(1);
assert_eq!(history.entries()[0].item.as_text(), Some("old"));
assert_eq!(history.entries()[0].annotation, "old-ann");
let previous = history.replace_entries(vec![HistoryEntry::new(
Item::user_message("restored"),
"restored-ann".to_string(),
)]);
assert_eq!(previous.len(), 1);
assert_eq!(history.entries()[0].item.as_text(), Some("restored"));
assert_eq!(history.entries()[0].annotation, "restored-ann");
}
+27 -14
View File
@@ -58,6 +58,7 @@ async fn test_callback_llm_retry_event() {
max_attempts: 2,
total_timeout: Duration::from_secs(1),
});
let mut history = agen::History::new();
let notices = Arc::new(Mutex::new(Vec::new()));
let sink = notices.clone();
@@ -65,8 +66,11 @@ async fn test_callback_llm_retry_event() {
sink.lock().unwrap().push((llm_call, notice.clone()));
});
let result = engine.run("retry once").await;
assert!(result.is_ok(), "engine should succeed after one retry");
let result = engine.run(&mut history, "retry once").await;
assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"engine should succeed after one retry"
);
let notices = notices.lock().unwrap();
assert_eq!(notices.len(), 1);
@@ -91,6 +95,7 @@ async fn test_callback_text_block_events() {
let client = MockLlmClient::new(events);
let mut engine = Engine::new(client);
let mut history = agen::History::new();
let text_deltas = Arc::new(Mutex::new(Vec::new()));
let text_completes = Arc::new(Mutex::new(Vec::new()));
@@ -108,9 +113,12 @@ async fn test_callback_text_block_events() {
});
});
// Mutable::run consumes self, returns (Locked, EngineResult)
let result = engine.run("Greet me").await;
assert!(result.is_ok(), "Engine should complete");
// Mutable::run consumes self, returns (Locked, EngineRunExit)
let result = engine.run(&mut history, "Greet me").await;
assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete"
);
let deltas = text_deltas.lock().unwrap();
assert_eq!(deltas.len(), 2);
@@ -137,6 +145,7 @@ async fn test_callback_tool_call_complete() {
let client = MockLlmClient::new(events);
let mut engine = Engine::new(client);
let mut history = agen::History::new();
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
let tool_completes = Arc::new(Mutex::new(Vec::new()));
@@ -154,8 +163,8 @@ async fn test_callback_tool_call_complete() {
});
});
// Mutable::run consumes self, returns (Locked, EngineResult)
let _ = engine.run("Weather please").await;
// Mutable::run consumes self, returns (Locked, EngineRunExit)
let _ = engine.run(&mut history, "Weather please").await;
let starts = tool_starts.lock().unwrap();
assert_eq!(starts.len(), 1);
@@ -183,6 +192,7 @@ async fn test_callback_turn_events() {
let client = MockLlmClient::new(events);
let mut engine = Engine::new(client);
let mut history = agen::History::new();
let turn_starts = Arc::new(Mutex::new(Vec::new()));
let turn_ends = Arc::new(Mutex::new(Vec::new()));
@@ -197,9 +207,9 @@ async fn test_callback_turn_events() {
ends.lock().unwrap().push(turn);
});
// Mutable::run consumes self, returns (Locked, EngineResult)
let result = engine.run("Do something").await;
assert!(result.is_ok());
// Mutable::run consumes self, returns (Locked, EngineRunExit)
let result = engine.run(&mut history, "Do something").await;
assert!(matches!(result.result, agen::EngineRunExit::Finished));
let starts = turn_starts.lock().unwrap();
let ends = turn_ends.lock().unwrap();
@@ -254,6 +264,7 @@ async fn test_callback_tool_result_events() {
let client = MockLlmClient::new(events);
let mut engine = Engine::new(client);
let mut history = agen::History::new();
engine.register_tool(fixed_tool(
"fixed",
@@ -276,7 +287,7 @@ async fn test_callback_tool_result_events() {
));
});
let _ = engine.run("call it").await;
let _ = engine.run(&mut history, "call it").await;
let observed = captured.lock().unwrap();
assert_eq!(observed.len(), 1);
@@ -330,6 +341,7 @@ async fn test_callback_tool_result_error_path() {
let client = MockLlmClient::new(events);
let mut engine = Engine::new(client);
let mut history = agen::History::new();
engine.register_tool(erroring_tool("erroring", "boom"));
@@ -345,7 +357,7 @@ async fn test_callback_tool_result_error_path() {
));
});
let _ = engine.run("fail it").await;
let _ = engine.run(&mut history, "fail it").await;
let observed = captured.lock().unwrap();
assert_eq!(observed.len(), 1);
@@ -374,6 +386,7 @@ async fn test_callback_usage_events() {
let client = MockLlmClient::new(events);
let mut engine = Engine::new(client);
let mut history = agen::History::new();
let usage_events = Arc::new(Mutex::new(Vec::new()));
@@ -382,8 +395,8 @@ async fn test_callback_usage_events() {
usages.lock().unwrap().push(event.clone());
});
// Mutable::run consumes self, returns (Locked, EngineResult)
let _ = engine.run("Hello").await;
// Mutable::run consumes self, returns (Locked, EngineRunExit)
let _ = engine.run(&mut history, "Hello").await;
let usages = usage_events.lock().unwrap();
assert_eq!(usages.len(), 1);
+8 -1
View File
@@ -19,6 +19,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
pub struct MockLlmClient {
responses: Arc<Vec<Vec<Event>>>,
call_count: Arc<AtomicUsize>,
requests: Arc<Mutex<Vec<Request>>>,
}
impl MockLlmClient {
@@ -30,6 +31,7 @@ impl MockLlmClient {
Self {
responses: Arc::new(responses),
call_count: Arc::new(AtomicUsize::new(0)),
requests: Arc::new(Mutex::new(Vec::new())),
}
}
@@ -41,6 +43,10 @@ impl MockLlmClient {
pub fn event_count(&self) -> usize {
self.responses.iter().map(|v| v.len()).sum()
}
pub fn requests(&self) -> Vec<Request> {
self.requests.lock().unwrap().clone()
}
}
#[async_trait]
@@ -51,8 +57,9 @@ impl LlmClient for MockLlmClient {
async fn stream(
&self,
_request: Request,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
self.requests.lock().unwrap().push(request);
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
if count >= self.responses.len() {
return Err(ClientError::Api {
+16 -5
View File
@@ -134,11 +134,15 @@ async fn test_engine_simple_text_response() {
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
let engine = Engine::new(client);
let mut history = agen::History::new();
// Send a simple message (Mutable::run consumes self, returns tuple)
let result = engine.run("Hello").await;
let result = engine.run(&mut history, "Hello").await;
assert!(result.is_ok(), "Engine should complete successfully");
assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete successfully"
);
}
/// Verify that Engine can correctly process responses containing tool calls
@@ -156,6 +160,7 @@ async fn test_engine_tool_call() {
let client = MockLlmClient::from_fixture(&fixture_path).unwrap();
let mut engine = Engine::new(client);
let mut history = agen::History::new();
// Register tool
let weather_tool = MockWeatherTool::new();
@@ -163,7 +168,9 @@ async fn test_engine_tool_call() {
engine.register_tool(weather_tool.definition());
// Send message (Mutable::run consumes self, returns tuple)
let _result = engine.run("What's the weather in Tokyo?").await;
let _result = engine
.run(&mut history, "What's the weather in Tokyo?")
.await;
// Verify tool was called
// Note: max_turns=1 so no request is sent after tool result
@@ -195,11 +202,15 @@ async fn test_engine_with_programmatic_events() {
let client = MockLlmClient::new(events);
let engine = Engine::new(client);
let mut history = agen::History::new();
// Mutable::run consumes self, returns tuple
let result = engine.run("Greet me").await;
let result = engine.run(&mut history, "Greet me").await;
assert!(result.is_ok(), "Engine should complete successfully");
assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete successfully"
);
}
/// Verify that ToolCallCollector correctly collects ToolCall from ToolUse block events
+322 -74
View File
@@ -9,9 +9,12 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use agen::Item;
use agen::interceptor::{
Interceptor, PreRequestAction, PreToolAction, ToolCallInfo, TurnEndAction,
};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use agen::{Engine, EngineError};
use agen::{Engine, EngineError, EngineRunExit, History, StopReason};
use async_trait::async_trait;
use common::MockLlmClient;
@@ -39,36 +42,37 @@ fn test_mutable_set_system_prompt() {
fn test_mutable_history_manipulation() {
let client = MockLlmClient::new(vec![]);
let mut engine = Engine::new(client);
let mut history: History = History::new();
// Initial state is empty
assert!(engine.history().is_empty());
assert!(history.is_empty());
// Add to history
engine
.append_history(vec![Item::user_message("Hello")])
.append_history(&mut history, vec![Item::user_message("Hello")])
.unwrap();
engine
.append_history(vec![Item::assistant_message("Hi there!")])
.append_history(&mut history, vec![Item::assistant_message("Hi there!")])
.unwrap();
assert_eq!(engine.history().len(), 2);
assert_eq!(history.len(), 2);
// Append to history via the callback-aware API.
engine
.append_history(vec![Item::user_message("How are you?")])
.append_history(&mut history, vec![Item::user_message("How are you?")])
.unwrap();
assert_eq!(engine.history().len(), 3);
assert_eq!(history.len(), 3);
// Clear history
engine.clear_history();
assert!(engine.history().is_empty());
engine.clear_history(&mut history);
assert!(history.is_empty());
// Set history
let items = vec![
Item::user_message("Test"),
Item::assistant_message("Response"),
];
engine.set_history(items);
assert_eq!(engine.history().len(), 2);
engine.set_history(&mut history, items);
assert_eq!(history.len(), 2);
}
/// Verify that Engine can be constructed using builder pattern
@@ -76,9 +80,10 @@ fn test_mutable_history_manipulation() {
fn test_mutable_builder_pattern() {
let client = MockLlmClient::new(vec![]);
let engine = Engine::new(client).system_prompt("System prompt");
let history: History = History::new();
assert_eq!(engine.get_system_prompt(), Some("System prompt"));
assert!(engine.history().is_empty());
assert!(history.is_empty());
}
/// Verify that multiple items can be added with append_history and callbacks fire.
@@ -88,6 +93,7 @@ fn test_mutable_append_history() {
let observed = Arc::new(Mutex::new(Vec::new()));
let observed_for_callback = Arc::clone(&observed);
let mut engine = Engine::new(client);
let mut history: History = History::new();
engine.on_history_append(move |item| {
if let Some(text) = item.as_text() {
observed_for_callback.lock().unwrap().push(text.to_string());
@@ -96,18 +102,21 @@ fn test_mutable_append_history() {
});
engine
.append_history(vec![Item::user_message("First")])
.append_history(&mut history, vec![Item::user_message("First")])
.unwrap();
engine
.append_history(vec![
Item::assistant_message("Response 1"),
Item::user_message("Second"),
Item::assistant_message("Response 2"),
])
.append_history(
&mut history,
vec![
Item::assistant_message("Response 1"),
Item::user_message("Second"),
Item::assistant_message("Response 2"),
],
)
.unwrap();
assert_eq!(engine.history().len(), 4);
assert_eq!(history.len(), 4);
assert_eq!(
observed.lock().unwrap().as_slice(),
["First", "Response 1", "Second", "Response 2"]
@@ -182,6 +191,7 @@ async fn history_append_failure_stops_before_tool_execution() {
]);
let tool = CountingTool::new("count_tool");
let mut engine = Engine::new(client);
let mut history: History = History::new();
engine.register_tool(tool.definition());
engine.on_history_append(|item| {
if item.is_tool_call() {
@@ -191,15 +201,15 @@ async fn history_append_failure_stops_before_tool_execution() {
}
});
let mut engine = engine.lock();
let error = engine.run("use the tool").await.unwrap_err();
let mut engine = engine.lock(&history);
let exit = engine.run(&mut history, "use the tool").await;
assert!(
matches!(error, EngineError::HistoryAppend(ref message) if message == "simulated ENOSPC")
matches!(exit, EngineRunExit::Interrupted(StopReason::Unexpected(EngineError::HistoryAppend(ref message))) if message == "simulated ENOSPC")
);
assert_eq!(tool.call_count(), 0);
assert_eq!(engine.history().len(), 1);
assert_eq!(engine.history()[0].as_text(), Some("use the tool"));
assert_eq!(history.len(), 1);
assert_eq!(history.entries()[0].item.as_text(), Some("use the tool"));
}
// =============================================================================
@@ -211,21 +221,22 @@ async fn history_append_failure_stops_before_tool_execution() {
fn test_lock_transition() {
let client = MockLlmClient::new(vec![]);
let mut engine = Engine::new(client);
let mut history: History = History::new();
engine.set_system_prompt("System");
engine
.append_history(vec![Item::user_message("Hello")])
.append_history(&mut history, vec![Item::user_message("Hello")])
.unwrap();
engine
.append_history(vec![Item::assistant_message("Hi")])
.append_history(&mut history, vec![Item::assistant_message("Hi")])
.unwrap();
// Lock
let locked_engine = engine.lock();
let locked_engine = engine.lock(&history);
// History and system prompt are still accessible in Locked state
assert_eq!(locked_engine.get_system_prompt(), Some("System"));
assert_eq!(locked_engine.history().len(), 2);
assert_eq!(history.len(), 2);
assert_eq!(locked_engine.locked_prefix_len(), 2);
}
@@ -234,21 +245,22 @@ fn test_lock_transition() {
fn test_unlock_transition() {
let client = MockLlmClient::new(vec![]);
let mut engine = Engine::new(client);
let mut history: History = History::new();
engine
.append_history(vec![Item::user_message("Hello")])
.append_history(&mut history, vec![Item::user_message("Hello")])
.unwrap();
let locked_engine = engine.lock();
let locked_engine = engine.lock(&history);
// Unlock
let mut engine = locked_engine.unlock();
// History operations are available again in Mutable state
engine
.append_history(vec![Item::assistant_message("Hi")])
.append_history(&mut history, vec![Item::assistant_message("Hi")])
.unwrap();
engine.clear_history();
assert!(engine.history().is_empty());
engine.clear_history(&mut history);
assert!(history.is_empty());
}
// =============================================================================
@@ -269,20 +281,20 @@ async fn test_mutable_run_updates_history() -> Result<(), EngineError> {
let client = MockLlmClient::new(events);
let engine = Engine::new(client);
let mut history: History = History::new();
// Execute (Mutable::run consumes self, returns EngineRunOutput)
let out = engine.run("Hi there").await?;
let engine = out.engine;
let _out = engine.run(&mut history, "Hi there").await;
// History is updated
let history = engine.history();
let entries = history.entries();
assert_eq!(history.len(), 2); // user + assistant
// User message
assert_eq!(history[0].as_text(), Some("Hi there"));
assert_eq!(entries[0].item.as_text(), Some("Hi there"));
// Assistant message
assert_eq!(history[1].as_text(), Some("Hello, I'm an assistant!"));
assert_eq!(entries[1].item.as_text(), Some("Hello, I'm an assistant!"));
Ok(())
}
@@ -313,35 +325,36 @@ async fn test_locked_multi_turn_history_accumulation() {
]);
let engine = Engine::new(client).system_prompt("You are helpful.");
let mut history: History = History::new();
// Lock (after setting system prompt)
let mut locked_engine = engine.lock();
let mut locked_engine = engine.lock(&history);
assert_eq!(locked_engine.locked_prefix_len(), 0); // No items yet
// Turn 1
let result1 = locked_engine.run("Hello!").await;
assert!(result1.is_ok());
assert_eq!(locked_engine.history().len(), 2); // user + assistant
let result1 = locked_engine.run(&mut history, "Hello!").await;
assert!(matches!(result1, EngineRunExit::Finished));
assert_eq!(history.len(), 2); // user + assistant
// Turn 2
let result2 = locked_engine.run("Can you help me?").await;
assert!(result2.is_ok());
assert_eq!(locked_engine.history().len(), 4); // 2 * (user + assistant)
let result2 = locked_engine.run(&mut history, "Can you help me?").await;
assert!(matches!(result2, EngineRunExit::Finished));
assert_eq!(history.len(), 4); // 2 * (user + assistant)
// Verify history contents
let history = locked_engine.history();
let entries = history.entries();
// Turn 1 user message
assert_eq!(history[0].as_text(), Some("Hello!"));
assert_eq!(entries[0].item.as_text(), Some("Hello!"));
// Turn 1 assistant message
assert_eq!(history[1].as_text(), Some("Nice to meet you!"));
assert_eq!(entries[1].item.as_text(), Some("Nice to meet you!"));
// Turn 2 user message
assert_eq!(history[2].as_text(), Some("Can you help me?"));
assert_eq!(entries[2].item.as_text(), Some("Can you help me?"));
// Turn 2 assistant message
assert_eq!(history[3].as_text(), Some("I can help with that."));
assert_eq!(entries[3].item.as_text(), Some("I can help with that."));
}
/// Verify that locked_prefix_len correctly records history length at lock time
@@ -367,26 +380,33 @@ async fn test_locked_prefix_len_tracking() {
]);
let mut engine = Engine::new(client);
let mut history: History = History::new();
// Add items beforehand
engine
.append_history(vec![Item::user_message("Pre-existing message 1")])
.append_history(
&mut history,
vec![Item::user_message("Pre-existing message 1")],
)
.unwrap();
engine
.append_history(vec![Item::assistant_message("Pre-existing response 1")])
.append_history(
&mut history,
vec![Item::assistant_message("Pre-existing response 1")],
)
.unwrap();
assert_eq!(engine.history().len(), 2);
assert_eq!(history.len(), 2);
// Lock
let mut locked_engine = engine.lock();
let mut locked_engine = engine.lock(&history);
assert_eq!(locked_engine.locked_prefix_len(), 2); // 2 items at lock time
// Execute turn
locked_engine.run("New message").await.unwrap();
locked_engine.run(&mut history, "New message").await;
// History grows but locked_prefix_len remains unchanged
assert_eq!(locked_engine.history().len(), 4); // 2 + 2
assert_eq!(history.len(), 4); // 2 + 2
assert_eq!(locked_engine.locked_prefix_len(), 2); // Unchanged
}
@@ -413,18 +433,22 @@ async fn test_turn_count_increment() -> Result<(), EngineError> {
]);
let engine = Engine::new(client);
let mut history: History = History::new();
assert_eq!(engine.turn_count(), 0);
assert_eq!(engine.llm_call_count(), 0);
// First run consumes Mutable, returns EngineRunOutput
let mut engine = engine.run("First").await?.engine;
let mut engine = engine.run(&mut history, "First").await.engine;
assert_eq!(engine.turn_count(), 1);
// Retry not yet implemented → AgentTurn:LlmCall is 1:1.
assert_eq!(engine.llm_call_count(), 1);
// Subsequent runs on Locked take &mut self
engine.run("Second").await?;
assert!(matches!(
engine.run(&mut history, "Second").await,
EngineRunExit::Finished
));
assert_eq!(engine.turn_count(), 2);
assert_eq!(engine.llm_call_count(), 2);
@@ -444,28 +468,29 @@ async fn test_unlock_edit_relock() {
]]);
let mut engine = Engine::new(client);
let mut history: History = History::new();
engine
.append_history(vec![
Item::user_message("Hello"),
Item::assistant_message("Hi"),
])
.append_history(
&mut history,
vec![Item::user_message("Hello"), Item::assistant_message("Hi")],
)
.unwrap();
// Lock -> Unlock
let locked = engine.lock();
let locked = engine.lock(&history);
assert_eq!(locked.locked_prefix_len(), 2);
let mut unlocked = locked.unlock();
// Edit history
unlocked.clear_history();
unlocked.clear_history(&mut history);
unlocked
.append_history(vec![Item::user_message("Fresh start")])
.append_history(&mut history, vec![Item::user_message("Fresh start")])
.unwrap();
// Re-lock
let relocked = unlocked.lock();
assert_eq!(relocked.history().len(), 1);
let relocked = unlocked.lock(&history);
assert_eq!(history.len(), 1);
assert_eq!(relocked.locked_prefix_len(), 1);
}
@@ -508,19 +533,26 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
]);
let mut engine = Engine::new(client);
let mut history: History = History::new();
let tool_a = CountingTool::new("tool_a");
engine.register_tool(tool_a.definition());
let mut locked = engine.lock();
locked.run("first").await.expect("first run");
let mut locked = engine.lock(&history);
assert!(matches!(
locked.run(&mut history, "first").await,
EngineRunExit::Finished
));
assert_eq!(tool_a.call_count(), 1, "tool_a should be called once");
let mut unlocked = locked.unlock();
let tool_b = CountingTool::new("tool_b");
unlocked.register_tool(tool_b.definition());
let mut relocked = unlocked.lock();
relocked.run("second").await.expect("second run");
let mut relocked = unlocked.lock(&history);
assert!(matches!(
relocked.run(&mut history, "second").await,
EngineRunExit::Finished
));
assert_eq!(tool_a.call_count(), 1, "tool_a should not be called again");
assert_eq!(tool_b.call_count(), 1, "tool_b should be called once");
@@ -535,8 +567,9 @@ async fn test_lock_unlock_relock_tools_remain_effective() {
fn test_system_prompt_preserved_in_locked_state() {
let client = MockLlmClient::new(vec![]);
let engine = Engine::new(client).system_prompt("Important system prompt");
let history: History = History::new();
let locked = engine.lock();
let locked = engine.lock(&history);
assert_eq!(locked.get_system_prompt(), Some("Important system prompt"));
let unlocked = locked.unlock();
@@ -551,13 +584,228 @@ fn test_system_prompt_preserved_in_locked_state() {
fn test_system_prompt_change_after_unlock() {
let client = MockLlmClient::new(vec![]);
let engine = Engine::new(client).system_prompt("Original prompt");
let history: History = History::new();
let locked = engine.lock();
let locked = engine.lock(&history);
let mut unlocked = locked.unlock();
unlocked.set_system_prompt("New prompt");
assert_eq!(unlocked.get_system_prompt(), Some("New prompt"));
let relocked = unlocked.lock();
let relocked = unlocked.lock(&history);
assert_eq!(relocked.get_system_prompt(), Some("New prompt"));
}
fn completed_text_events() -> Vec<Event> {
vec![
Event::text_block_start(0),
Event::text_delta(0, "done"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]
}
struct YieldOnce {
calls: AtomicUsize,
}
#[async_trait]
impl Interceptor for YieldOnce {
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
PreRequestAction::Yield
} else {
PreRequestAction::Continue
}
}
}
struct PauseToolOnce {
calls: AtomicUsize,
}
#[async_trait]
impl Interceptor for PauseToolOnce {
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
PreToolAction::Pause
} else {
PreToolAction::Continue
}
}
}
struct ContinueTurnOnce {
calls: AtomicUsize,
}
#[async_trait]
impl Interceptor for ContinueTurnOnce {
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
TurnEndAction::ContinueWithMessages(vec![Item::system_message("continue")])
} else {
TurnEndAction::Finish
}
}
}
#[tokio::test]
async fn max_turns_is_scoped_to_each_fresh_run() {
let mut history: History = History::new();
let responses = vec![completed_text_events(), completed_text_events()];
let mut engine = Engine::new(MockLlmClient::with_responses(responses));
engine.set_max_turns(Some(1));
let mut engine = engine.lock(&history);
assert!(matches!(
engine.run(&mut history, "first").await,
EngineRunExit::Finished
));
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), None);
assert!(matches!(
engine.run(&mut history, "second").await,
EngineRunExit::Finished
));
assert_eq!(engine.turn_count(), 2);
assert_eq!(engine.active_run_turn_count(), None);
}
#[tokio::test]
async fn yielded_resume_keeps_the_same_unspent_turn_budget() {
let mut history: History = History::new();
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_max_turns(Some(1));
engine.set_interceptor(YieldOnce {
calls: AtomicUsize::new(0),
});
let mut engine = engine.lock(&history);
assert!(matches!(
engine.run(&mut history, "start").await,
EngineRunExit::Yielded
));
assert_eq!(engine.turn_count(), 0);
assert_eq!(engine.active_run_turn_count(), Some(0));
assert!(matches!(
engine.resume(&mut history).await,
EngineRunExit::Finished
));
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), None);
}
#[tokio::test]
async fn paused_tool_resume_does_not_reset_the_consumed_turn_budget() {
let mut history: History = History::new();
let events = vec![
Event::tool_use_start(0, "call_1", "count_tool"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let tool = CountingTool::new("count_tool");
let mut engine = Engine::new(MockLlmClient::new(events));
engine.set_max_turns(Some(1));
engine.register_tool(tool.definition());
engine.set_interceptor(PauseToolOnce {
calls: AtomicUsize::new(0),
});
let mut engine = engine.lock(&history);
assert!(matches!(
engine.run(&mut history, "call it").await,
EngineRunExit::Paused
));
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), Some(1));
assert_eq!(tool.call_count(), 0);
assert!(matches!(
engine.resume(&mut history).await,
EngineRunExit::Interrupted(StopReason::LimitReached)
));
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.active_run_turn_count(), None);
assert_eq!(tool.call_count(), 1, "the consumed turn's tool still runs");
}
#[tokio::test]
async fn fresh_input_abandons_a_paused_run_and_starts_a_new_budget() {
let mut history: History = History::new();
let tool_events = vec![
Event::tool_use_start(0, "call_1", "count_tool"),
Event::tool_input_delta(0, "{}"),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
];
let client = MockLlmClient::with_responses(vec![tool_events, completed_text_events()]);
let tool = CountingTool::new("count_tool");
let mut engine = Engine::new(client);
engine.set_max_turns(Some(1));
engine.register_tool(tool.definition());
engine.set_interceptor(PauseToolOnce {
calls: AtomicUsize::new(0),
});
let mut engine = engine.lock(&history);
assert!(matches!(
engine.run(&mut history, "pause").await,
EngineRunExit::Paused
));
assert_eq!(engine.active_run_turn_count(), Some(1));
assert!(matches!(
engine.run(&mut history, "replace").await,
EngineRunExit::Finished
));
assert_eq!(engine.turn_count(), 2);
assert_eq!(engine.active_run_turn_count(), None);
assert_eq!(tool.call_count(), 1, "pending-tool semantics are unchanged");
}
#[tokio::test]
async fn interceptor_continuation_consumes_the_logical_run_budget() {
let mut history: History = History::new();
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_max_turns(Some(1));
engine.set_interceptor(ContinueTurnOnce {
calls: AtomicUsize::new(0),
});
let mut engine = engine.lock(&history);
assert!(matches!(
engine.run(&mut history, "start").await,
EngineRunExit::Interrupted(StopReason::LimitReached)
));
assert_eq!(engine.turn_count(), 1);
assert_eq!(engine.llm_call_count(), 1);
assert_eq!(engine.active_run_turn_count(), None);
}
#[tokio::test]
async fn restored_active_run_budget_is_enforced_before_another_llm_call() {
let mut history: History = History::new();
let mut engine = Engine::new(MockLlmClient::new(completed_text_events()));
engine.set_max_turns(Some(1));
engine.set_turn_count(7);
engine.set_active_run_turn_count(Some(1));
let mut engine = engine.lock(&history);
assert!(matches!(
engine.resume(&mut history).await,
EngineRunExit::Interrupted(StopReason::LimitReached)
));
assert_eq!(engine.turn_count(), 7);
assert_eq!(engine.llm_call_count(), 0);
assert_eq!(engine.active_run_turn_count(), None);
}
+678 -10
View File
@@ -6,12 +6,13 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use agen::Engine;
use agen::interceptor::{Interceptor, PostToolAction, PreToolAction, ToolCallInfo, ToolResultInfo};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolResult,
ToolResultDisposition,
};
use agen::{Engine, History, Item, ToolExecutionPolicy};
use async_trait::async_trait;
mod common;
@@ -70,6 +71,144 @@ impl Tool for SlowTool {
}
}
#[derive(Clone)]
struct FirstAttemptHangsTool {
calls: Arc<AtomicUsize>,
}
impl FirstAttemptHangsTool {
fn new() -> Self {
Self {
calls: Arc::new(AtomicUsize::new(0)),
}
}
fn definition(&self) -> ToolDefinition {
let tool = self.clone();
Arc::new(move || {
let meta = ToolMeta::new("hang_once")
.description("Hangs on the first execution attempt")
.input_schema(serde_json::json!({"type": "object"}));
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
})
}
fn call_count(&self) -> usize {
self.calls.load(Ordering::SeqCst)
}
}
#[async_trait]
impl Tool for FirstAttemptHangsTool {
async fn execute(
&self,
_input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let attempt = self.calls.fetch_add(1, Ordering::SeqCst);
if attempt == 0 {
std::future::pending::<()>().await;
}
Ok("completed on retry".to_string().into())
}
}
#[derive(Clone)]
struct CooperativeCancelTool {
calls: Arc<AtomicUsize>,
cancelled: Arc<tokio::sync::Notify>,
}
impl CooperativeCancelTool {
fn new() -> Self {
Self {
calls: Arc::new(AtomicUsize::new(0)),
cancelled: Arc::new(tokio::sync::Notify::new()),
}
}
fn definition(&self) -> ToolDefinition {
let tool = self.clone();
Arc::new(move || {
let meta = ToolMeta::new("cooperative")
.description("Returns bounded progress after cancellation")
.input_schema(serde_json::json!({"type": "object"}));
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
})
}
}
#[async_trait]
impl Tool for CooperativeCancelTool {
async fn execute(
&self,
_input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.cancelled.notified().await;
Err(ToolError::Cancelled(ToolOutput {
summary: "cooperative command cancelled".to_string(),
content: Some("stdout before cancellation\nstderr before cancellation".to_string()),
attachments: Vec::new(),
}))
}
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
self.cancelled.notify_one();
Ok(())
}
}
#[derive(Clone)]
struct SafePauseTool {
calls: Arc<AtomicUsize>,
cancellations: Arc<AtomicUsize>,
release: Arc<tokio::sync::Notify>,
}
impl SafePauseTool {
fn new() -> Self {
Self {
calls: Arc::new(AtomicUsize::new(0)),
cancellations: Arc::new(AtomicUsize::new(0)),
release: Arc::new(tokio::sync::Notify::new()),
}
}
fn definition(&self) -> ToolDefinition {
let tool = self.clone();
Arc::new(move || {
let meta = ToolMeta::new("safe_pause")
.description("Waits for a safe-boundary release")
.input_schema(serde_json::json!({"type": "object"}));
(meta, Arc::new(tool.clone()) as Arc<dyn Tool>)
})
}
}
#[async_trait]
impl Tool for SafePauseTool {
async fn execute(
&self,
_input_json: &str,
_ctx: ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
self.calls.fetch_add(1, Ordering::SeqCst);
self.release.notified().await;
Ok(ToolOutput {
summary: "safe-boundary complete".to_string(),
content: Some("safe-boundary complete".to_string()),
attachments: Vec::new(),
})
}
async fn cancel(&self, _call_id: &str) -> Result<(), ToolError> {
self.cancellations.fetch_add(1, Ordering::SeqCst);
Ok(())
}
}
#[derive(Clone)]
struct ContextRecordingTool {
name: String,
@@ -145,6 +284,7 @@ async fn test_parallel_tool_execution() {
],
]);
let mut engine = Engine::new(client);
let mut history: History = History::new();
let tool1 = SlowTool::new("slow_tool_1", 100);
let tool2 = SlowTool::new("slow_tool_2", 100);
let tool3 = SlowTool::new("slow_tool_3", 100);
@@ -159,7 +299,7 @@ async fn test_parallel_tool_execution() {
let start = Instant::now();
// Mutable::run consumes self, returns (Locked, EngineResult)
let _result = engine.run("Run all tools").await;
let _result = engine.run(&mut history, "Run all tools").await;
let elapsed = start.elapsed();
// Verify all tools were called
@@ -178,6 +318,450 @@ async fn test_parallel_tool_execution() {
println!("Parallel execution completed in {:?}", elapsed);
}
#[tokio::test]
async fn completed_results_commit_before_publish_without_waiting_for_siblings() {
let client = MockLlmClient::with_responses(vec![
vec![
Event::tool_use_start(0, "call_slow", "slow_first"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::tool_use_start(1, "call_fast", "fast_second"),
Event::tool_input_delta(1, r#"{}"#),
Event::tool_use_stop(1),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
vec![
Event::text_block_start(0),
Event::text_delta(0, "Done"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
]);
let client_probe = client.clone();
let mut engine = Engine::new(client);
engine.register_tool(SlowTool::new("slow_first", 100).definition());
engine.register_tool(SlowTool::new("fast_second", 5).definition());
let observed = Arc::new(Mutex::new(Vec::<String>::new()));
let published = observed.clone();
engine.on_tool_result(move |result| {
published
.lock()
.unwrap()
.push(format!("publish:{}", result.tool_use_id));
});
let committed = observed.clone();
let mut annotate = move |item: &Item| {
if let Item::ToolResult { call_id, .. } = item {
committed.lock().unwrap().push(format!("commit:{call_id}"));
}
Ok(())
};
let mut history = History::new();
let _ = engine
.run_with_annotation(&mut history, "run both", &mut annotate)
.await;
observed.lock().unwrap().push("run-returned".to_string());
assert_eq!(
observed.lock().unwrap().as_slice(),
[
"commit:call_fast",
"publish:call_fast",
"commit:call_slow",
"publish:call_slow",
"run-returned",
]
);
let committed_order: Vec<_> = history
.iter()
.filter_map(|entry| match &entry.item {
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
assert_eq!(committed_order, ["call_fast", "call_slow"]);
let requests = client_probe.requests();
let projected_order: Vec<_> = requests[1]
.items
.iter()
.filter_map(|item| match item {
Item::ToolResult { call_id, .. } => Some(call_id.as_str()),
_ => None,
})
.collect();
assert_eq!(projected_order, ["call_slow", "call_fast"]);
}
#[tokio::test]
async fn cancellation_preserves_completed_results_and_resume_skips_them() {
let client = MockLlmClient::with_responses(vec![
vec![
Event::tool_use_start(0, "call_hang", "hang_once"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::tool_use_start(1, "call_fast_a", "fast_a"),
Event::tool_input_delta(1, r#"{}"#),
Event::tool_use_stop(1),
Event::tool_use_start(2, "call_fast_b", "fast_b"),
Event::tool_input_delta(2, r#"{}"#),
Event::tool_use_stop(2),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
vec![
Event::text_block_start(0),
Event::text_delta(0, "Recovered"),
Event::text_block_stop(0, None),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
]);
let mut engine = Engine::new(client);
let hanging = FirstAttemptHangsTool::new();
let fast_a = SlowTool::new("fast_a", 1);
let fast_b = SlowTool::new("fast_b", 2);
engine.register_tool(hanging.definition());
engine.register_tool(fast_a.definition());
engine.register_tool(fast_b.definition());
let cancel = engine.cancel_sender();
let cancel_task = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
cancel.send(()).await.unwrap();
});
let mut history = History::new();
let output = engine.run(&mut history, "start").await;
let mut engine = output.engine;
cancel_task.await.unwrap();
let completed_before_resume = history
.iter()
.filter(|entry| {
matches!(
&entry.item,
Item::ToolResult { call_id, .. }
if call_id == "call_fast_a" || call_id == "call_fast_b"
)
})
.count();
let unknown_before_resume = history
.iter()
.filter(|entry| {
matches!(
&entry.item,
Item::ToolResult {
call_id,
disposition: ToolResultDisposition::OutcomeUnknown,
..
} if call_id == "call_hang"
)
})
.count();
assert_eq!(completed_before_resume, 2);
assert_eq!(unknown_before_resume, 1);
assert_eq!(fast_a.call_count(), 1);
assert_eq!(fast_b.call_count(), 1);
assert_eq!(hanging.call_count(), 1);
let _ = engine.resume(&mut history).await;
assert_eq!(
fast_a.call_count(),
1,
"completed call must not be re-executed"
);
assert_eq!(
fast_b.call_count(),
1,
"completed call must not be re-executed"
);
assert_eq!(
hanging.call_count(),
1,
"OutcomeUnknown is terminal and must not be re-executed"
);
let completed_after_resume = history
.iter()
.filter(|entry| {
matches!(
&entry.item,
Item::ToolResult { call_id, .. }
if call_id == "call_fast_a" || call_id == "call_fast_b"
)
})
.count();
assert_eq!(completed_after_resume, 2);
assert_eq!(
history
.iter()
.filter(|entry| {
matches!(
&entry.item,
Item::ToolResult {
call_id,
disposition: ToolResultDisposition::OutcomeUnknown,
..
} if call_id == "call_hang"
)
})
.count(),
1
);
}
#[tokio::test]
async fn cooperative_cancellation_commits_bounded_terminal_output() {
let client = MockLlmClient::with_responses(vec![vec![
Event::tool_use_start(0, "call_cooperative", "cooperative"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]]);
let mut engine = Engine::new(client);
let tool = CooperativeCancelTool::new();
engine.register_tool(tool.definition());
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
let published = observed.clone();
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
let committed = observed.clone();
let mut annotate = move |item: &Item| {
if matches!(item, Item::ToolResult { .. }) {
committed.lock().unwrap().push("committed");
}
Ok(())
};
let cancel = engine.cancel_sender();
let cancel_task = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
cancel.send(()).await.unwrap();
});
let mut history = History::new();
let output = engine
.run_with_annotation(&mut history, "start", &mut annotate)
.await;
observed.lock().unwrap().push("run-returned");
cancel_task.await.unwrap();
assert_eq!(
observed.lock().unwrap().as_slice(),
["committed", "published", "run-returned"]
);
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
let terminal: Vec<_> = history
.iter()
.filter_map(|entry| match &entry.item {
Item::ToolResult {
call_id,
disposition,
content,
..
} if call_id == "call_cooperative" => Some((*disposition, content.as_deref())),
_ => None,
})
.collect();
assert_eq!(terminal.len(), 1);
assert_eq!(terminal[0].0, ToolResultDisposition::Cancelled);
assert_eq!(
terminal[0].1,
Some("stdout before cancellation\nstderr before cancellation")
);
assert!(matches!(
output.result,
agen::EngineRunExit::Interrupted(agen::StopReason::Cancelled)
));
}
#[tokio::test]
async fn pause_waits_for_started_tool_terminal_without_cancelling_provider() {
let client = MockLlmClient::with_responses(vec![vec![
Event::tool_use_start(0, "call_safe_pause", "safe_pause"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]]);
let mut engine = Engine::new(client);
let tool = SafePauseTool::new();
engine.register_tool(tool.definition());
let pause = engine.pause_sender();
let calls = Arc::clone(&tool.calls);
let release = Arc::clone(&tool.release);
let control = tokio::spawn(async move {
tokio::time::timeout(Duration::from_secs(1), async {
while calls.load(Ordering::SeqCst) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("tool execution starts");
pause.send(()).await.unwrap();
tokio::time::sleep(Duration::from_millis(50)).await;
release.notify_one();
});
let started_at = std::time::Instant::now();
let mut history = History::new();
let output = engine.run(&mut history, "pause safely").await;
control.await.unwrap();
assert!(started_at.elapsed() >= Duration::from_millis(50));
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
assert_eq!(tool.cancellations.load(Ordering::SeqCst), 0);
assert!(matches!(output.result, agen::EngineRunExit::Paused));
assert!(history.iter().any(|entry| matches!(
&entry.item,
Item::ToolResult {
call_id,
disposition: ToolResultDisposition::Success,
..
} if call_id == "call_safe_pause"
)));
}
#[tokio::test]
async fn pause_escalates_to_explicit_cancel_and_confirm_after_safe_boundary_deadline() {
let client = MockLlmClient::with_responses(vec![vec![
Event::tool_use_start(0, "call_pause_cancel", "cooperative"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]]);
let mut engine = Engine::new(client);
engine.set_tool_execution_policy(ToolExecutionPolicy {
pause_safe_boundary_timeout: Duration::from_millis(20),
cancellation_request_timeout: Duration::from_millis(50),
terminal_confirmation_timeout: Duration::from_millis(100),
});
let tool = CooperativeCancelTool::new();
engine.register_tool(tool.definition());
let pause = engine.pause_sender();
let calls = Arc::clone(&tool.calls);
let control = tokio::spawn(async move {
tokio::time::timeout(Duration::from_secs(1), async {
while calls.load(Ordering::SeqCst) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("tool execution starts");
pause.send(()).await.unwrap();
});
let mut history = History::new();
let output = engine.run(&mut history, "pause with escalation").await;
control.await.unwrap();
assert!(matches!(output.result, agen::EngineRunExit::Paused));
assert!(history.iter().any(|entry| matches!(
&entry.item,
Item::ToolResult {
call_id,
disposition: ToolResultDisposition::Cancelled,
..
} if call_id == "call_pause_cancel"
)));
}
#[tokio::test]
async fn cancellation_completion_race_commits_one_terminal_output() {
for iteration in 0..24u64 {
let client = MockLlmClient::with_responses(vec![
vec![
Event::tool_use_start(0, "call_racy", "racy"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
],
vec![Event::Status(StatusEvent {
status: ResponseStatus::Completed,
})],
]);
let mut engine = Engine::new(client);
let delay = 2 + iteration % 3;
let tool = SlowTool::new("racy", delay);
engine.register_tool(tool.definition());
let cancel = engine.cancel_sender();
let cancel_task = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(delay)).await;
let _ = cancel.send(()).await;
});
let mut history = History::new();
let _ = engine.run(&mut history, "race").await;
cancel_task.await.unwrap();
let terminal_count = history
.iter()
.filter(|entry| {
matches!(
&entry.item,
Item::ToolResult { call_id, .. } if call_id == "call_racy"
)
})
.count();
assert_eq!(terminal_count, 1, "iteration {iteration}");
assert_eq!(tool.call_count(), 1, "iteration {iteration}");
}
}
#[tokio::test]
async fn tool_result_commit_failure_prevents_publication() {
let client = MockLlmClient::with_responses(vec![vec![
Event::tool_use_start(0, "call_fast", "fast"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]]);
let mut engine = Engine::new(client);
engine.register_tool(SlowTool::new("fast", 1).definition());
let published = Arc::new(AtomicUsize::new(0));
let published_probe = published.clone();
engine.on_tool_result(move |_| {
published_probe.fetch_add(1, Ordering::SeqCst);
});
let mut history = History::new();
let mut reject_tool_result = |item: &Item| {
if matches!(item, Item::ToolResult { .. }) {
Err("session log unavailable".to_string())
} else {
Ok(())
}
};
let _ = engine
.run_with_annotation(&mut history, "start", &mut reject_tool_result)
.await;
assert_eq!(published.load(Ordering::SeqCst), 0);
assert!(
history
.iter()
.all(|entry| !matches!(entry.item, Item::ToolResult { .. }))
);
}
#[tokio::test]
async fn test_tool_execution_context_order_and_batch_id() {
let client = MockLlmClient::with_responses(vec![
@@ -205,13 +789,14 @@ async fn test_tool_execution_context_order_and_batch_id() {
],
]);
let mut engine = Engine::new(client);
let mut history: History = History::new();
let contexts = Arc::new(Mutex::new(Vec::new()));
engine.register_tool(ContextRecordingTool::new("record_a", contexts.clone()).definition());
engine.register_tool(ContextRecordingTool::new("record_b", contexts.clone()).definition());
engine.register_tool(ContextRecordingTool::new("record_c", contexts.clone()).definition());
let _ = engine.run("record contexts").await;
let _ = engine.run(&mut history, "record contexts").await;
let mut contexts = contexts.lock().unwrap().clone();
contexts.sort_by_key(|ctx| ctx.call_index);
@@ -256,11 +841,12 @@ async fn test_tool_execution_context_batch_id_changes_between_batches() {
],
]);
let mut engine = Engine::new(client);
let mut history: History = History::new();
let contexts = Arc::new(Mutex::new(Vec::new()));
engine.register_tool(ContextRecordingTool::new("record", contexts.clone()).definition());
let _ = engine.run("record batches").await;
let _ = engine.run(&mut history, "record batches").await;
let contexts = contexts.lock().unwrap().clone();
assert_eq!(contexts.len(), 2);
@@ -298,6 +884,7 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
],
]);
let mut engine = Engine::new(client);
let mut history: History = History::new();
let executed_contexts = Arc::new(Mutex::new(Vec::new()));
let pre_contexts = Arc::new(Mutex::new(Vec::new()));
let post_contexts = Arc::new(Mutex::new(Vec::new()));
@@ -344,7 +931,9 @@ async fn test_tool_execution_context_for_skipped_and_synthetic_paths() {
post_contexts: post_contexts.clone(),
});
let _ = engine.run("record skipped and synthetic contexts").await;
let _ = engine
.run(&mut history, "record skipped and synthetic contexts")
.await;
let mut pre_contexts = pre_contexts.lock().unwrap().clone();
pre_contexts.sort_by_key(|ctx| ctx.call_index);
@@ -389,6 +978,7 @@ async fn test_before_tool_call_skip() {
let client = MockLlmClient::new(events);
let mut engine = Engine::new(client);
let mut history: History = History::new();
let allowed_tool = SlowTool::new("allowed_tool", 10);
let blocked_tool = SlowTool::new("blocked_tool", 10);
@@ -416,7 +1006,7 @@ async fn test_before_tool_call_skip() {
engine.set_interceptor(BlockingPolicy);
// Mutable::run consumes self, returns (Locked, EngineResult)
let _result = engine.run("Test hook").await;
let _result = engine.run(&mut history, "Test hook").await;
// allowed_tool is called, but blocked_tool is not
assert_eq!(
@@ -457,6 +1047,7 @@ async fn test_post_tool_call_modification() {
]);
let mut engine = Engine::new(client);
let mut history: History = History::new();
#[derive(Clone)]
struct SimpleTool;
@@ -503,9 +1094,12 @@ async fn test_post_tool_call_modification() {
});
// Mutable::run consumes self, returns (Locked, EngineResult)
let result = engine.run("Test modification").await;
let result = engine.run(&mut history, "Test modification").await;
assert!(result.is_ok(), "Engine should complete");
assert!(
matches!(result.result, agen::EngineRunExit::Finished),
"Engine should complete"
);
// Verify hook was called and content was modified
let content = modified_content.lock().unwrap().clone();
@@ -540,6 +1134,7 @@ async fn test_before_tool_call_synthetic_result_committed() {
],
]);
let mut engine = Engine::new(client);
let mut history: History = History::new();
let blocked_tool = SlowTool::new("blocked_tool", 10);
let blocked_clone = blocked_tool.clone();
engine.register_tool(blocked_tool.definition());
@@ -558,10 +1153,10 @@ async fn test_before_tool_call_synthetic_result_committed() {
engine.set_interceptor(SyntheticPolicy);
let result = engine.run("Test synthetic result").await.unwrap();
let _result = engine.run(&mut history, "Test synthetic result").await;
assert_eq!(blocked_clone.call_count(), 0, "Blocked tool should not run");
assert!(result.engine.history().iter().any(|item| matches!(
assert!(history.items().any(|item| matches!(
item,
agen::Item::ToolResult {
call_id,
@@ -571,3 +1166,76 @@ async fn test_before_tool_call_synthetic_result_committed() {
} if call_id == "call_1" && summary == "permission denied"
)));
}
#[tokio::test]
async fn post_tool_abort_commits_confirmed_result_before_stopping_run() {
let client = MockLlmClient::new(vec![
Event::tool_use_start(0, "call_confirmed", "confirmed"),
Event::tool_input_delta(0, r#"{}"#),
Event::tool_use_stop(0),
Event::Status(StatusEvent {
status: ResponseStatus::Completed,
}),
]);
let mut engine = Engine::new(client);
let tool = SlowTool::new("confirmed", 1);
engine.register_tool(tool.definition());
struct AbortAfterResult;
#[async_trait]
impl Interceptor for AbortAfterResult {
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
PostToolAction::Abort("policy stopped the run".to_string())
}
}
engine.set_interceptor(AbortAfterResult);
let observed = Arc::new(Mutex::new(Vec::<&'static str>::new()));
let published = observed.clone();
engine.on_tool_result(move |_| published.lock().unwrap().push("published"));
let committed = observed.clone();
let mut annotate = move |item: &Item| {
if matches!(item, Item::ToolResult { .. }) {
committed.lock().unwrap().push("committed");
}
Ok(())
};
let mut history = History::new();
let output = engine
.run_with_annotation(&mut history, "run confirmed tool", &mut annotate)
.await;
observed.lock().unwrap().push("run-returned");
assert_eq!(tool.call_count(), 1);
assert_eq!(
observed.lock().unwrap().as_slice(),
["committed", "published", "run-returned"]
);
assert!(matches!(
output.result,
agen::EngineRunExit::Interrupted(agen::StopReason::Unexpected(
agen::EngineError::Aborted(ref reason)
)) if reason == "policy stopped the run"
));
let terminal: Vec<_> = history
.iter()
.filter_map(|entry| match &entry.item {
Item::ToolResult {
call_id,
disposition,
..
} if call_id == "call_confirmed" => Some(*disposition),
_ => None,
})
.collect();
assert_eq!(terminal, [ToolResultDisposition::Success]);
assert!(!history.iter().any(|entry| matches!(
&entry.item,
Item::ToolResult {
call_id,
disposition: ToolResultDisposition::OutcomeUnknown,
..
} if call_id == "call_confirmed"
)));
}
+26 -22
View File
@@ -13,12 +13,12 @@
mod common;
use agen::Engine;
use agen::Item;
use agen::llm_client::event::{
BlockMetadata, BlockStart, BlockStop, BlockType, Event, ReasoningBlockData, ResponseStatus,
StatusEvent,
};
use agen::{Engine, History};
use common::MockLlmClient;
fn reasoning_block(text: impl Into<String>, data: ReasoningBlockData) -> Vec<Event> {
@@ -65,15 +65,15 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
]);
let client = MockLlmClient::new(events);
let engine = Engine::new(client);
let out = engine.run("question?").await.expect("run ok");
let engine = out.engine;
let mut history: History = History::new();
let _out = engine.run(&mut history, "question?").await;
let history = engine.history();
let entries = history.entries();
// user / reasoning / assistant_message
assert_eq!(history.len(), 3, "history: {history:?}");
assert!(matches!(history[0], Item::Message { .. }));
match &history[1] {
assert!(matches!(entries[0].item, Item::Message { .. }));
match &entries[1].item {
Item::Reasoning {
text, signature, ..
} => {
@@ -82,7 +82,7 @@ async fn anthropic_thinking_round_trips_signature_into_history() {
}
other => panic!("expected Reasoning, got {other:?}"),
}
assert_eq!(history[2].as_text(), Some("Here's the answer"));
assert_eq!(entries[2].item.as_text(), Some("Here's the answer"));
}
/// OpenAI Responses 風: encrypted_content + summary を持った reasoning が
@@ -109,11 +109,11 @@ async fn openai_reasoning_round_trips_encrypted_and_summary() {
]);
let client = MockLlmClient::new(events);
let engine = Engine::new(client);
let out = engine.run("q").await.expect("run ok");
let engine = out.engine;
let mut history: History = History::new();
let _out = engine.run(&mut history, "q").await;
let history = engine.history();
match &history[1] {
let entries = history.entries();
match &entries[1].item {
Item::Reasoning {
text,
summary,
@@ -155,13 +155,13 @@ async fn reasoning_precedes_text_in_assistant_burst() {
}));
let client = MockLlmClient::new(events);
let engine = Engine::new(client);
let out = engine.run("q").await.expect("run ok");
let engine = out.engine;
let mut history: History = History::new();
let _out = engine.run(&mut history, "q").await;
let history = engine.history();
let entries = history.entries();
// user / reasoning(先頭) / assistant_message
assert!(matches!(history[1], Item::Reasoning { .. }));
assert_eq!(history[2].as_text(), Some("intermediate"));
assert!(matches!(entries[1].item, Item::Reasoning { .. }));
assert_eq!(entries[2].item.as_text(), Some("intermediate"));
}
/// resume シナリオ: history.json 由来の Item::Reasoning(signature) を Engine に
@@ -207,14 +207,18 @@ async fn injected_reasoning_survives_into_outgoing_request() {
};
let mut engine = Engine::new(client);
let mut history: History = History::new();
// resume: 既存 history を流し込む
engine.set_history(vec![
Item::user_message("prior question"),
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
Item::assistant_message("prior answer"),
]);
engine.set_history(
&mut history,
vec![
Item::user_message("prior question"),
Item::reasoning("prior thinking").with_signature("SIG-PRIOR"),
Item::assistant_message("prior answer"),
],
);
let _ = engine.run("follow up").await.expect("run ok");
let _ = engine.run(&mut history, "follow up").await;
let req = captured
.lock()
+3 -2
View File
@@ -1,4 +1,4 @@
use agen::Engine;
use agen::{Engine, History};
use agen::llm_client::capability::{
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
};
@@ -22,7 +22,8 @@ fn main() {
cap,
);
let engine = Engine::new(client);
let mut locked = engine.lock();
let history = History::new();
let mut locked = engine.lock(&history);
let def: agen::tool::ToolDefinition = Arc::new(|| panic!("unused"));
let _ = locked.register_tool(def);
}
@@ -1,8 +1,8 @@
error[E0599]: no method named `register_tool` found for struct `Engine<HttpTransport<AnthropicScheme>, Locked>` in the current scope
--> tests/ui/locked_register_tool.rs:27:20
--> tests/ui/locked_register_tool.rs:28:20
|
27 | let _ = locked.register_tool(def);
28 | let _ = locked.register_tool(def);
| ^^^^^^^^^^^^^ method not found in `Engine<HttpTransport<AnthropicScheme>, Locked>`
|
= note: the method was found for
- `Engine<C>`
- `Engine<C, Mutable, A>`
+26 -2
View File
@@ -24,7 +24,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
match slug {
CODER_REVIEW_FLOW_SLUG => Some(BuiltinFlowSource {
slug: CODER_REVIEW_FLOW_SLUG,
revision: 3,
revision: 4,
path: "builtin/flows/coder-review.dcdl",
content: CODER_REVIEW_FLOW_SOURCE,
}),
@@ -35,7 +35,7 @@ pub fn builtin_flow_source(slug: &str) -> Option<BuiltinFlowSource> {
pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
const SOURCES: &[BuiltinFlowSource] = &[BuiltinFlowSource {
slug: CODER_REVIEW_FLOW_SLUG,
revision: 3,
revision: 4,
path: "builtin/flows/coder-review.dcdl",
content: CODER_REVIEW_FLOW_SOURCE,
}];
@@ -46,6 +46,30 @@ pub fn builtin_flow_sources() -> &'static [BuiltinFlowSource] {
mod tests {
use super::*;
#[test]
fn coder_review_flow_uses_current_selector_ref_review_contract() {
let source = builtin_flow_source(CODER_REVIEW_FLOW_SLUG).expect("coder review Flow");
for required in [
"OpenMergeRequest",
"ShowMergeRequest",
"ReviewMergeRequest",
"CompleteMergeRequest",
"existing Merge Request `selector_from`",
"Target-only movement does not invalidate",
] {
assert!(source.content.contains(required), "missing {required}");
}
for stale in [
"MergeRequestOpen",
"MergeRequestShow",
"MergeRequestReview",
"MergeRequestComplete",
"new immutable revision",
] {
assert!(!source.content.contains(stale), "stale contract {stale}");
}
}
#[test]
fn every_builtin_flow_compiles_and_matches_catalog_identity() {
assert!(!builtin_flow_sources().is_empty());
+54
View File
@@ -279,4 +279,58 @@ mod tests {
assert_eq!(grep.matched_files, 2);
assert!(!grep.output.contains("c.txt"));
}
#[test]
fn grep_content_groups_lines_by_file_and_marks_matches() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("first.txt"),
"before\nneedle one\nafter\nomitted one\nomitted two\nbefore distant\nneedle distant\nafter distant\n",
)
.unwrap();
std::fs::write(temp.path().join("second.txt"), "needle two\n").unwrap();
let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone());
let grep = run_grep(
&root,
root.clone(),
GrepRequest {
pattern: "needle".to_string(),
path: FsPath::root(),
glob: Some("*.txt".to_string()),
output_mode: GrepOutputMode::Content,
case_insensitive: false,
before_context: 1,
after_context: 1,
multiline: false,
file_type: None,
limit: 20,
offset: 0,
},
&readable,
)
.unwrap();
assert_eq!(grep.match_count, 3);
assert_eq!(grep.matched_files, 2);
assert_eq!(
grep.output,
concat!(
"first.txt\n",
" 1 │ before\n",
" > 2 │ needle one\n",
" 3 │ after\n",
"\n",
" 6 │ before distant\n",
" > 7 │ needle distant\n",
" 8 │ after distant\n",
"\n",
"second.txt\n",
" > 1 │ needle two\n",
)
);
assert_eq!(grep.output.matches("first.txt").count(), 1);
assert_eq!(grep.output.matches("second.txt").count(), 1);
}
}
+49 -14
View File
@@ -1,3 +1,5 @@
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use crate::FsAccessPolicy;
@@ -57,20 +59,11 @@ impl GrepReport {
}
}
GrepOutputMode::Content => {
for line in &self.lines {
let separator = if line.is_match { ':' } else { '-' };
let path = logical_display(root, &line.path);
if self.show_line_numbers
&& let Some(number) = line.line_number
{
output.push_str(&format!(
"{path}{separator}{number}{separator}{}\n",
line.text
));
} else {
output.push_str(&format!("{path}{separator}{}\n", line.text));
}
}
output.push_str(&render_content_lines(
root,
&self.lines,
self.show_line_numbers,
));
}
}
GrepResult {
@@ -82,6 +75,48 @@ impl GrepReport {
}
}
fn render_content_lines(root: &Path, lines: &[ContentLine], show_line_numbers: bool) -> String {
let mut grouped = BTreeMap::<&Path, Vec<&ContentLine>>::new();
for line in lines {
grouped.entry(&line.path).or_default().push(line);
}
let mut output = String::new();
for (file_index, (path, file_lines)) in grouped.into_iter().enumerate() {
if file_index > 0 {
output.push('\n');
}
let _ = writeln!(output, "{}", logical_display(root, path));
let number_width = file_lines
.iter()
.filter_map(|line| line.line_number)
.map(|number| number.to_string().len())
.max()
.unwrap_or(1);
let mut previous_line_end = None;
for line in file_lines {
if let (Some(previous_end), Some(number)) = (previous_line_end, line.line_number)
&& number > previous_end
{
let _ = writeln!(output, "");
}
let marker = if line.is_match { '>' } else { ' ' };
if show_line_numbers && let Some(number) = line.line_number {
let _ = writeln!(output, " {marker} {number:>number_width$} │ {}", line.text);
} else {
let _ = writeln!(output, " {marker} │ {}", line.text);
}
previous_line_end = line
.line_number
.map(|number| number + line.text.split('\n').count() as u64);
}
}
output
}
fn logical_display(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
+19 -2
View File
@@ -946,7 +946,7 @@ fn apply_role_profile(
value["feature"]["sub_worker"] = serde_json::json!({ "enabled": sub_worker });
value["feature"]["flow"] = serde_json::json!({ "enabled": slug == "coder" });
value["feature"]["worker"] = serde_json::json!({
"enabled": matches!(slug, "companion" | "orchestrator"),
"enabled": slug == "orchestrator",
"direct_spawn": slug != "orchestrator"
});
value["feature"]["manage_workdir"] = serde_json::json!({
@@ -1408,7 +1408,22 @@ mod tests {
}
#[test]
fn builtin_companion_can_manage_workdirs() {
fn builtin_coder_uses_sub_worker_control_without_worker_control() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "coder"),
ProfileResolveOptions::with_worker_name("coder-worker"),
)
.unwrap();
assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(!resolved.manifest.feature.worker.enabled);
}
#[test]
fn builtin_companion_uses_sub_worker_control_without_worker_control() {
let tmp = TempDir::new().unwrap();
let resolved = ProfileResolver::new()
.with_workspace_base(tmp.path())
@@ -1419,6 +1434,8 @@ mod tests {
.unwrap();
assert!(resolved.manifest.feature.manage_workdir.enabled);
assert!(resolved.manifest.feature.sub_worker.enabled);
assert!(!resolved.manifest.feature.worker.enabled);
}
#[test]
+4 -1
View File
@@ -9,7 +9,7 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
use crate::schema::{EvidenceKind, EvidenceOrigin, SourceEvidenceRef, SourceRef};
/// Current flat staging schema version.
pub const STAGING_SCHEMA_VERSION: u32 = 2;
@@ -80,6 +80,8 @@ pub struct StagingEvidence {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_range: Option<[u64; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<EvidenceOrigin>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub excerpt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
@@ -159,6 +161,7 @@ mod tests {
id: "E001".into(),
kind: EvidenceKind::new(EvidenceKind::MESSAGE),
entry_range: Some([10, 12]),
origin: None,
excerpt: Some("extract candidate taxonomy".into()),
summary: Some("User and assistant discussed staging kinds".into()),
};
+37
View File
@@ -67,6 +67,40 @@ impl EvidenceKind {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum EvidenceOriginKind {
HumanInput,
WorkerInput,
FlowInstruction,
BackendInstruction,
ModelOutput,
ToolOutput,
DerivedSummary,
LegacyUnknown,
}
/// Bounded origin snapshot attached to extraction evidence. This is audit
/// metadata only and cannot authorize Workspace operations.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct EvidenceOrigin {
pub kind: EvidenceOriginKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub worker_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow_definition_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flow_definition_revision: Option<u64>,
}
/// Host-resolved source/evidence metadata for an individual staging claim.
///
/// This deliberately stores only bounded anchor metadata: stable ids, entry
@@ -86,6 +120,9 @@ pub struct SourceEvidenceRef {
/// Host-assigned evidence id within the referenced evidence set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence_id: Option<String>,
/// Trusted typed origin snapshot for this logical evidence entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<EvidenceOrigin>,
/// Extensible evidence kind tag.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence_kind: Option<EvidenceKind>,
+4 -1
View File
@@ -10,7 +10,10 @@ mod decision;
mod request;
mod summary;
pub use common::{EvidenceKind, Frontmatter, SourceEvidenceRef, SourceRef, split_frontmatter};
pub use common::{
EvidenceKind, EvidenceOrigin, EvidenceOriginKind, Frontmatter, SourceEvidenceRef, SourceRef,
split_frontmatter,
};
pub use decision::{DecisionFrontmatter, DecisionStatus};
pub use request::RequestFrontmatter;
pub use summary::SummaryFrontmatter;
+27 -5
View File
@@ -416,7 +416,7 @@ impl MergeRequestStore {
let conflict:bool=t.query_row("SELECT EXISTS(SELECT 1 FROM merge_request_ticket_relations rel JOIN merge_requests mr ON mr.workspace_id=rel.workspace_id AND mr.merge_request_id=rel.merge_request_id WHERE rel.workspace_id=?1 AND rel.ticket_id=?2 AND mr.state='open')",params![i.auth.workspace_id,i.ticket_id],|r|r.get(0))?;
if conflict {
return Err(MergeRequestError::Conflict(
"Ticket already has an open Merge Request".into(),
"Ticket already has an open Merge Request; use ShowMergeRequest and advance the existing selector_from with a normal non-force push instead of opening a replacement Merge Request or adding a revision".into(),
));
}
let now = i.now.to_rfc3339();
@@ -575,12 +575,16 @@ impl MergeRequestStore {
));
};
if subject != i.current_subject_ref {
let reason = format!(
"selector_from moved from requested subject {subject} to current subject {}; fresh review of the exact current source ref is required",
i.current_subject_ref
);
let e = ReviewCancelledEvent {
event_id: Uuid::now_v7().to_string(),
sequence: next_seq(&t, &ws, &mr)?,
request_event_id: req,
subject_ref: subject,
reason: "selector_from moved before submission".into(),
reason,
created_at: i.now,
};
insert_event(&t, &ws, &mr, "review_cancelled", &e, i.now, None)?;
@@ -667,9 +671,27 @@ impl MergeRequestStore {
}
match (&i.current_subject_ref, &review) {
(None, _) => b.push("selector_from could not be resolved".into()),
(Some(_), None) => b.push("current source ref has no valid review".into()),
(_, Some(r)) if r.decision == ReviewDecision::RequestChanges => {
b.push("current source ref requests changes".into())
(Some(subject_ref), None) => {
let previous_review_subject = mr.thread.iter().rev().find_map(|event| match event {
MergeRequestThreadEvent::ReviewRequested(value) => {
Some(value.subject_ref.as_str())
}
MergeRequestThreadEvent::Review(value) => Some(value.subject_ref.as_str()),
_ => None,
});
match previous_review_subject.filter(|previous| *previous != subject_ref) {
Some(previous) => b.push(format!(
"selector_from moved from reviewed/requested subject {previous} to current subject {subject_ref}; request a fresh review for this exact source ref (selector_to movement alone does not invalidate source approval)"
)),
None => b.push(format!(
"current source ref {subject_ref} has no valid review; request a fresh review for this exact source ref"
)),
}
}
(Some(subject_ref), Some(r)) if r.decision == ReviewDecision::RequestChanges => {
b.push(format!(
"current source ref {subject_ref} requests changes; advance the existing selector_from with a normal non-force push, then request a fresh review for the exact new source ref"
))
}
_ => {}
}
+72 -3
View File
@@ -181,16 +181,85 @@ fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_retur
.is_err()
);
let mr = s.get("W", "T").unwrap();
let cancellation = mr.thread.iter().find_map(|event| match event {
MergeRequestThreadEvent::ReviewCancelled(value) => Some(value),
_ => None,
});
assert!(
mr.thread
.iter()
.any(|e| matches!(e, MergeRequestThreadEvent::ReviewCancelled(_)))
cancellation
.as_ref()
.is_some_and(|value| value.reason.contains("selector_from moved")
&& value.reason.contains("fresh review"))
);
assert_eq!(
mr.effective_review("source-a").map(|r| &r.event_id),
Some(&approved.event_id)
);
}
#[test]
fn same_selector_source_advancement_requires_fresh_review_and_preserves_target_only_approval() {
let (_d, s) = fixture();
open(&s);
let first = approve(&s, "source-1", "one");
let stale = s
.readiness(ReadinessCheck {
ticket_id: "T".into(),
current_subject_ref: Some("source-2".into()),
auth: auth(),
})
.unwrap();
assert!(!stale.ready);
assert!(stale.review.is_none());
assert!(stale.blockers.iter().any(|blocker| {
blocker.contains("selector_from moved from reviewed/requested subject source-1")
&& blocker.contains("current subject source-2")
&& blocker.contains("fresh review")
}));
assert_eq!(
s.get("W", "T")
.unwrap()
.effective_review("source-1")
.map(|review| &review.event_id),
Some(&first.event_id)
);
let second = approve(&s, "source-2", "two");
let ready = s
.readiness(ReadinessCheck {
ticket_id: "T".into(),
current_subject_ref: Some("source-2".into()),
auth: auth(),
})
.unwrap();
assert!(ready.ready);
assert_eq!(
ready.review.as_ref().map(|review| &review.event_id),
Some(&second.event_id)
);
// The target can move from target-1 to target-2 without changing selector_from
// or invalidating the exact-source approval. Completion consumes refreshed
// integration evidence for the current target pair.
let merged = s
.complete(CompleteMergeRequest {
operation_id: "target-moved".into(),
ticket_id: "T".into(),
current_subject_ref: "source-2".into(),
target_ref_before: "target-2".into(),
target_ref_after: "integrated-target-2".into(),
approval_event_id: second.event_id,
strategy: MergeStrategy::FastForward,
resolution: ConflictResolution::None,
auth: auth(),
now: at(5),
})
.unwrap();
assert_eq!(merged.approved_source_ref, "source-2");
assert_eq!(merged.target_ref_before, "target-2");
assert_eq!(merged.target_ref_after, "integrated-target-2");
}
#[test]
fn review_revocation_invalidates_readiness() {
let (_d, s) = fixture();
+104 -27
View File
@@ -281,11 +281,44 @@ impl Method {
/// Presentation category for an Internal Worker exposed through its parent's
/// protocol stream. Internal Workers never become independently addressable
/// protocol subjects.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum InternalWorkerKind {
SubWorker,
Service { kind: String },
}
/// Stable parent-owned lifecycle for one compaction run.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct CompactionLifecycle {
pub schema_version: u32,
pub compaction_id: String,
pub revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub internal_worker: Option<InternalWorkerRef>,
pub state: CompactionLifecycleState,
/// Milliseconds since the Unix epoch.
pub started_at_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ended_at_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub new_segment_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompactionLifecycleState {
Running,
Done,
Failed,
Interrupted,
}
/// Stable presentation identity for one parent-owned Internal Worker session.
@@ -319,6 +352,18 @@ pub struct InternalWorkerSnapshot {
pub internal_workers: Vec<InternalWorkerSnapshot>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum ToolResultDisposition {
#[default]
Success,
Error,
Interrupted,
Cancelled,
OutcomeUnknown,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "event", content = "data", rename_all = "snake_case")]
@@ -468,6 +513,8 @@ pub enum Event {
/// summary-only, or when the result was pruned.
#[serde(default, skip_serializing_if = "Option::is_none")]
output: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
disposition: Option<ToolResultDisposition>,
#[serde(default)]
is_error: bool,
},
@@ -607,23 +654,18 @@ pub enum Event {
/// This is not part of LLM history or prompt context; clients may display it
/// briefly as operational status.
MemoryWorker(MemoryWorkerEvent),
/// Worker has started compacting the current session.
///
/// Fired immediately before a compaction run. Success is signalled by
/// `CompactDone` (with the new `SegmentId`); failure by `CompactFailed`.
/// Broadcast to all clients; not replayed to late subscribers.
CompactStart,
/// Compaction completed and the session was rotated.
///
/// `new_segment_id` is the UUID of the freshly created session that
/// replaced the old history.
CompactDone {
#[cfg_attr(feature = "typescript", ts(type = "string"))]
new_segment_id: uuid::Uuid,
/// Worker has started compacting the current session, or bound the run to its
/// observable Internal Worker. Revisions upsert one stable lifecycle item.
CompactStart {
lifecycle: CompactionLifecycle,
},
/// Compaction failed. The session is unchanged.
/// Compaction completed and the session was rotated.
CompactDone {
lifecycle: CompactionLifecycle,
},
/// Compaction failed or was cancelled. The session is unchanged.
CompactFailed {
error: String,
lifecycle: CompactionLifecycle,
},
Shutdown,
}
@@ -895,6 +937,7 @@ pub enum WorkerStatus {
Idle,
Running,
Paused,
Stopped,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -1732,45 +1775,74 @@ mod tests {
assert_eq!(parsed["data"]["timestamp_ms"], 1_700_000_000_000i64);
}
fn test_compaction_lifecycle(state: CompactionLifecycleState) -> CompactionLifecycle {
CompactionLifecycle {
schema_version: 2,
compaction_id: "0192f0e8-4d84-7d6e-a000-000000000000".into(),
revision: 1,
internal_worker: None,
state,
started_at_ms: 1_700_000_000_000,
ended_at_ms: None,
summary: None,
error: None,
new_segment_id: None,
}
}
#[test]
fn event_compact_start_roundtrip() {
let event = Event::CompactStart;
let event = Event::CompactStart {
lifecycle: test_compaction_lifecycle(CompactionLifecycleState::Running),
};
let json = serde_json::to_string(&event).unwrap();
assert_eq!(json, r#"{"event":"compact_start"}"#);
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "compact_start");
assert_eq!(parsed["data"]["lifecycle"]["state"], "running");
let decoded: Event = serde_json::from_str(&json).unwrap();
assert!(matches!(decoded, Event::CompactStart));
assert!(matches!(decoded, Event::CompactStart { lifecycle } if lifecycle.revision == 1));
}
#[test]
fn event_compact_done_roundtrip() {
let id = uuid::Uuid::parse_str("0192f0e8-4d84-7d6e-a000-000000000001").unwrap();
let event = Event::CompactDone { new_segment_id: id };
let mut lifecycle = test_compaction_lifecycle(CompactionLifecycleState::Done);
lifecycle.new_segment_id = Some(id.to_string());
lifecycle.summary = Some("accepted summary".into());
let event = Event::CompactDone { lifecycle };
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "compact_done");
assert_eq!(
parsed["data"]["new_segment_id"],
parsed["data"]["lifecycle"]["new_segment_id"],
"0192f0e8-4d84-7d6e-a000-000000000001"
);
let decoded: Event = serde_json::from_str(&json).unwrap();
match decoded {
Event::CompactDone { new_segment_id } => assert_eq!(new_segment_id, id),
Event::CompactDone { lifecycle } => {
assert_eq!(
lifecycle.new_segment_id.as_deref(),
Some(id.to_string().as_str())
)
}
other => panic!("expected CompactDone, got {other:?}"),
}
}
#[test]
fn event_compact_failed_roundtrip() {
let event = Event::CompactFailed {
error: "provider 429".into(),
};
let mut lifecycle = test_compaction_lifecycle(CompactionLifecycleState::Failed);
lifecycle.error = Some("provider 429".into());
let event = Event::CompactFailed { lifecycle };
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "compact_failed");
assert_eq!(parsed["data"]["error"], "provider 429");
assert_eq!(parsed["data"]["lifecycle"]["error"], "provider 429");
let decoded: Event = serde_json::from_str(&json).unwrap();
match decoded {
Event::CompactFailed { error } => assert_eq!(error, "provider 429"),
Event::CompactFailed { lifecycle } => {
assert_eq!(lifecycle.error.as_deref(), Some("provider 429"))
}
other => panic!("expected CompactFailed, got {other:?}"),
}
}
@@ -1781,6 +1853,7 @@ mod tests {
id: "call_1".into(),
summary: "Read 128 bytes".into(),
output: Some("hello world".into()),
disposition: Some(ToolResultDisposition::Success),
is_error: false,
};
let json = serde_json::to_string(&event).unwrap();
@@ -1797,11 +1870,13 @@ mod tests {
id,
summary,
output,
disposition,
is_error,
} => {
assert_eq!(id, "call_1");
assert_eq!(summary, "Read 128 bytes");
assert_eq!(output.as_deref(), Some("hello world"));
assert_eq!(disposition, Some(ToolResultDisposition::Success));
assert!(!is_error);
}
other => panic!("expected ToolResult, got {other:?}"),
@@ -1814,6 +1889,7 @@ mod tests {
id: "call_2".into(),
summary: "ok".into(),
output: None,
disposition: Some(ToolResultDisposition::Success),
is_error: false,
};
let json = serde_json::to_string(&event).unwrap();
@@ -1829,6 +1905,7 @@ mod tests {
id: "call_3".into(),
summary: "invalid argument".into(),
output: None,
disposition: Some(ToolResultDisposition::Error),
is_error: true,
};
let json = serde_json::to_string(&event).unwrap();
+8 -5
View File
@@ -4,11 +4,11 @@ use ts_rs::{Config, TS};
use crate::{
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
CommandStreamSlice, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
InFlightSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef,
InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary,
RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, TurnResult, WorkerEvent,
WorkerStatus,
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry,
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId,
RunResult, ScopeRule, Segment, ToolResultDisposition, TurnResult, WorkerEvent, WorkerStatus,
subscription::{
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
@@ -45,6 +45,7 @@ pub fn generated_protocol_types() -> String {
push_decl::<TurnResult>(&cfg, &mut output);
push_decl::<InvokeKind>(&cfg, &mut output);
push_decl::<RunResult>(&cfg, &mut output);
push_decl::<ToolResultDisposition>(&cfg, &mut output);
push_decl::<ErrorCode>(&cfg, &mut output);
push_decl::<Permission>(&cfg, &mut output);
push_decl::<InFlightToolCallState>(&cfg, &mut output);
@@ -53,6 +54,8 @@ pub fn generated_protocol_types() -> String {
push_decl::<CommandStreamSlice>(&cfg, &mut output);
push_decl::<CommandSnapshot>(&cfg, &mut output);
push_decl::<CommandEvent>(&cfg, &mut output);
push_decl::<CompactionLifecycleState>(&cfg, &mut output);
push_decl::<CompactionLifecycle>(&cfg, &mut output);
push_decl::<ScopeRule>(&cfg, &mut output);
push_decl::<CompletionEntry>(&cfg, &mut output);
push_decl::<RewindTargetId>(&cfg, &mut output);
+180
View File
@@ -0,0 +1,180 @@
//! Serializable history entries with restore-authoritative logical identity and origin.
use serde::{Deserialize, Serialize};
use crate::{LoggedItem, SessionId};
/// Stable logical identity of one model-visible history entry.
///
/// This value is generated at the trusted Worker session boundary and copied
/// unchanged across fork, rewind, compaction retention, restore, and reboot.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct LoggedSessionHistoryEntryId(pub String);
impl LoggedSessionHistoryEntryId {
pub fn new() -> Self {
Self(uuid::Uuid::now_v7().to_string())
}
}
impl Default for LoggedSessionHistoryEntryId {
fn default() -> Self {
Self::new()
}
}
/// Bounded subject snapshot. It is evidence, not a live authorization handle.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoggedWorkerSubject {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_id: Option<String>,
pub worker_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LoggedSessionHistoryOrigin {
HumanInput {
account_id: String,
},
WorkerInput {
actor: LoggedWorkerSubject,
},
FlowInstruction {
selector: String,
definition_id: String,
definition_revision: u64,
instance_id: String,
state_id: String,
},
BackendInstruction {
#[serde(default, skip_serializing_if = "Option::is_none")]
operation_id: Option<String>,
},
ModelOutput {
worker: LoggedWorkerSubject,
},
ToolOutput {
worker: LoggedWorkerSubject,
},
DerivedSummary,
LegacyUnknown,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoggedHistoryDerivation {
pub sources: Vec<LoggedSessionHistoryEntryId>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoggedSessionHistoryMetadata {
pub entry_id: LoggedSessionHistoryEntryId,
pub origin: LoggedSessionHistoryOrigin,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub derivation: Option<LoggedHistoryDerivation>,
}
impl LoggedSessionHistoryMetadata {
pub fn legacy_unknown() -> Self {
Self {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::LegacyUnknown,
derivation: None,
}
}
}
/// Persisted item and metadata are one value so transforms cannot reorder or
/// truncate one without the other.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LoggedHistoryEntry {
pub item: LoggedItem,
pub metadata: LoggedSessionHistoryMetadata,
}
/// Typed system-item history record. The typed system event remains available
/// to client replay while its model-visible projection carries the same stable
/// metadata used by live history.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LoggedSystemHistoryEntry {
pub item: crate::SystemItem,
pub metadata: LoggedSessionHistoryMetadata,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::LoggedRole;
use agen::llm_client::RequestConfig;
#[test]
fn logged_history_entry_round_trip_preserves_id_origin_and_derivation() {
let source_id = LoggedSessionHistoryEntryId::new();
let entry = LoggedHistoryEntry {
item: LoggedItem::Message {
role: LoggedRole::User,
content: vec![crate::LoggedContentPart::Text {
text: "preference".into(),
}],
},
metadata: LoggedSessionHistoryMetadata {
entry_id: LoggedSessionHistoryEntryId::new(),
origin: LoggedSessionHistoryOrigin::HumanInput {
account_id: "account-1".into(),
},
derivation: Some(LoggedHistoryDerivation {
sources: vec![source_id.clone()],
}),
},
};
let encoded = serde_json::to_vec(&entry).unwrap();
let decoded: LoggedHistoryEntry = serde_json::from_slice(&encoded).unwrap();
assert_eq!(decoded, entry);
assert_eq!(
decoded.metadata.derivation.unwrap().sources,
vec![source_id]
);
}
#[test]
fn annotated_segment_start_is_restore_visible_without_projecting_metadata() {
let session_id = uuid::Uuid::now_v7();
let history_entry = legacy_logged_history(LoggedItem::Message {
role: LoggedRole::Assistant,
content: vec![crate::LoggedContentPart::Text {
text: "answer".into(),
}],
});
let state = crate::collect_state(&[crate::LogEntry::AnnotatedSegmentStart {
ts: 1,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: vec![history_entry],
forked_from: None,
compacted_from: None,
}]);
assert_eq!(state.history[0].as_text(), Some("answer"));
}
}
/// Legacy Session Logs did not persist annotations. Decode helpers explicitly
/// create `LegacyUnknown`; they never infer Human/System authority from role or
/// plaintext.
pub fn legacy_logged_history(item: LoggedItem) -> LoggedHistoryEntry {
LoggedHistoryEntry {
item,
metadata: LoggedSessionHistoryMetadata::legacy_unknown(),
}
}
pub fn legacy_segment_history(
session_id: SessionId,
items: impl IntoIterator<Item = LoggedItem>,
) -> Vec<LoggedHistoryEntry> {
let _ = session_id;
items.into_iter().map(legacy_logged_history).collect()
}
+6
View File
@@ -32,6 +32,7 @@
pub mod event_trace;
pub mod fs_store;
pub mod history;
pub mod logged_item;
pub mod segment;
pub mod segment_log;
@@ -44,6 +45,11 @@ pub use agen::UsageRecord;
pub use agen::llm_client::types::{ContentPart, Item, Role};
pub use event_trace::{TraceEntry, TracePayload};
pub use fs_store::FsStore;
pub use history::{
LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry,
LoggedWorkerSubject, legacy_logged_history, legacy_segment_history,
};
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use segment::{
SegmentStartState, append_entry, append_system_item, classify_history_item,
+58 -9
View File
@@ -14,7 +14,7 @@
use agen::{
llm_client::types::{ContentPart, Item, Role},
tool::{Attachment, ImageAttachment},
tool::{Attachment, ImageAttachment, ToolResultDisposition},
};
use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _};
@@ -61,6 +61,8 @@ pub enum LoggedItem {
content: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
attachments: Vec<LoggedAttachment>,
#[serde(default, skip_serializing_if = "ToolResultDisposition::is_success")]
disposition: ToolResultDisposition,
#[serde(default, skip_serializing_if = "is_false")]
is_error: bool,
},
@@ -128,6 +130,7 @@ impl From<&Item> for LoggedItem {
summary,
content,
attachments,
disposition,
is_error,
..
} => Self::ToolResult {
@@ -135,6 +138,7 @@ impl From<&Item> for LoggedItem {
summary: summary.clone(),
content: content.clone(),
attachments: attachments.iter().map(LoggedAttachment::from).collect(),
disposition: *disposition,
is_error: *is_error,
},
Item::Reasoning {
@@ -184,15 +188,24 @@ impl From<LoggedItem> for Item {
summary,
content,
attachments,
disposition,
is_error,
} => Item::ToolResult {
id: None,
call_id,
summary,
content,
is_error,
attachments: attachments.into_iter().map(Attachment::from).collect(),
},
} => {
let disposition = if is_error && disposition.is_success() {
ToolResultDisposition::Error
} else {
disposition
};
Item::ToolResult {
id: None,
call_id,
summary,
content,
disposition,
is_error,
attachments: attachments.into_iter().map(Attachment::from).collect(),
}
}
LoggedItem::Reasoning {
text,
summary,
@@ -430,6 +443,42 @@ mod tests {
}
}
#[test]
fn outcome_unknown_tool_result_round_trips_as_terminal() {
let original = Item::tool_result_item_with_disposition_and_attachments(
"call_unknown",
"outcome unknown",
Some("bounded progress".to_string()),
ToolResultDisposition::OutcomeUnknown,
Vec::new(),
);
let logged: LoggedItem = (&original).into();
let json = serde_json::to_string(&logged).unwrap();
assert!(json.contains(r#""disposition":"outcome_unknown""#));
match Item::from(serde_json::from_str::<LoggedItem>(&json).unwrap()) {
Item::ToolResult {
disposition,
is_error,
..
} => {
assert_eq!(disposition, ToolResultDisposition::OutcomeUnknown);
assert!(is_error);
}
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn legacy_error_tool_result_infers_error_disposition() {
let legacy = r#"{"kind":"tool_result","call_id":"call_old","summary":"failed","content":null,"is_error":true}"#;
match Item::from(serde_json::from_str::<LoggedItem>(legacy).unwrap()) {
Item::ToolResult { disposition, .. } => {
assert_eq!(disposition, ToolResultDisposition::Error)
}
other => panic!("unexpected variant: {other:?}"),
}
}
#[test]
fn tool_result_persistence_round_trips_binary_attachments() {
let original = Item::tool_result_item_with_attachments(
+2
View File
@@ -307,6 +307,7 @@ pub fn save_run_completed(
segment_id: SegmentId,
result: EngineResult,
interrupted: bool,
active_run_turn_count: Option<usize>,
) -> Result<(), StoreError> {
append_entry(
store,
@@ -316,6 +317,7 @@ pub fn save_run_completed(
ts: segment_log::now_millis(),
interrupted,
result,
active_run_turn_count,
},
)
}
+209 -2
View File
@@ -14,6 +14,7 @@ use agen::{EngineResult, UsageRecord};
use protocol::{InvokeKind, Segment};
use serde::{Deserialize, Serialize};
use crate::history::{LoggedHistoryEntry, LoggedSystemHistoryEntry};
use crate::logged_item::LoggedItem;
use crate::system_item::SystemItem;
@@ -70,6 +71,20 @@ pub enum LogEntry {
compacted_from: Option<SegmentOrigin>,
},
/// Schema-v2 segment seed. Retained entries keep their stable logical
/// identity and origin across fork/compaction/restore.
AnnotatedSegmentStart {
ts: u64,
session_id: crate::SessionId,
system_prompt: Option<String>,
config: RequestConfig,
history: Vec<LoggedHistoryEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_from: Option<SegmentOrigin>,
#[serde(default, skip_serializing_if = "Option::is_none")]
compacted_from: Option<SegmentOrigin>,
},
/// IDLE → active marker. Records the start of a new self-driving
/// cycle (Invoke range). The range extends implicitly until the
/// next `Invoke` entry; this entry carries the trigger only — the
@@ -105,14 +120,37 @@ pub enum LogEntry {
extensions: Vec<SessionExtension>,
},
/// Schema-v2 user submission with its exact model-visible entries. Typed
/// Flow instructions and caller-attributed input remain separate entries.
AnnotatedUserInput {
ts: u64,
segments: Vec<Segment>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
extensions: Vec<SessionExtension>,
history: Vec<LoggedHistoryEntry>,
},
/// Schema-v2 model output and metadata committed as one journal record.
AnnotatedAssistantItem { ts: u64, entry: LoggedHistoryEntry },
/// One assistant-side item appended to history — assistant message,
/// reasoning, or tool call. Singular: one entry per history item so
/// the wire-side `Event::*` lane and on-disk LogEntry stay 1:1.
AssistantItem { ts: u64, item: LoggedItem },
/// Schema-v2 tool output and metadata committed as one journal record.
AnnotatedToolResult { ts: u64, entry: LoggedHistoryEntry },
/// One tool-execution result appended to history.
ToolResult { ts: u64, item: LoggedItem },
/// Schema-v2 typed system event and model-visible metadata committed
/// together.
AnnotatedSystemItem {
ts: u64,
entry: LoggedSystemHistoryEntry,
},
/// One typed agent-injected system item: notification, child-Worker
/// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each
/// `SystemItem` carries kind metadata that the LLM
@@ -125,11 +163,16 @@ pub enum LogEntry {
TurnEnd { ts: u64, turn_count: usize },
/// `run()` / `resume()` が `EngineResult` で正常終了した。
/// Audit-only metadata: replay は `interrupted` のみ反映する。
/// Replay restores both interruption state and any resumable logical-run
/// turn budget.
RunCompleted {
ts: u64,
interrupted: bool,
result: EngineResult,
/// AgentTurns consumed by a paused/yielded logical run. Terminal
/// outcomes persist `None`.
#[serde(default, skip_serializing_if = "Option::is_none")]
active_run_turn_count: Option<usize>,
},
/// `run()` / `resume()` が `EngineError` で終了した。
@@ -141,6 +184,15 @@ pub enum LogEntry {
message: String,
},
/// Restores an active logical-run budget at a segment boundary, notably
/// after compaction replaced the segment that held the original Invoke and
/// RunCompleted entries.
ActiveRunCheckpoint {
ts: u64,
active_turn_count: usize,
total_turn_count: usize,
},
/// A paused interrupted turn was explicitly abandoned without calling
/// `run()` or `resume()` again. Replay clears the interrupted marker so
/// the restored Worker is idle and future user input starts a normal new turn.
@@ -209,6 +261,8 @@ pub struct RestoredState {
pub config: RequestConfig,
pub history: Vec<Item>,
pub turn_count: usize,
/// AgentTurns consumed by the active paused/yielded logical run.
pub active_run_turn_count: Option<usize>,
pub last_run_interrupted: bool,
/// Number of entries replayed. `0` means the segment log was empty.
/// Writers track their own append count via the same counter so
@@ -238,6 +292,7 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
config: RequestConfig::default(),
history: Vec::new(),
turn_count: 0,
active_run_turn_count: None,
last_run_interrupted: false,
entries_count: 0,
usage_history: Vec::new(),
@@ -261,10 +316,27 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.config = config.clone();
state.history = history.iter().cloned().map(Item::from).collect();
}
LogEntry::AnnotatedSegmentStart {
session_id,
system_prompt,
config,
history,
..
} => {
state.session_id = Some(*session_id);
state.system_prompt = system_prompt.clone();
state.config = config.clone();
state.history = history
.iter()
.cloned()
.map(|entry| Item::from(entry.item))
.collect();
}
LogEntry::Invoke { .. } => {
// A terminal run record below clears or refines this. If the
// log ends first, restore must treat the turn as interrupted.
state.last_run_interrupted = true;
state.active_run_turn_count = Some(0);
}
LogEntry::UserInput {
segments,
@@ -280,6 +352,29 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
);
}
LogEntry::AnnotatedUserInput {
segments,
extensions,
history,
..
} => {
state
.history
.extend(history.iter().cloned().map(|entry| Item::from(entry.item)));
state.user_segments.push(segments.clone());
state.extensions.extend(
extensions
.iter()
.map(|extension| (extension.domain.clone(), extension.payload.clone())),
);
}
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => {
state.history.push(Item::from(entry.item.clone()));
}
LogEntry::AnnotatedSystemItem { entry, .. } => {
state.history.push(entry.item.to_history_item());
}
LogEntry::AssistantItem { item, .. } => {
state.history.push(Item::from(item.clone()));
}
@@ -290,16 +385,44 @@ pub fn collect_state(entries: &[LogEntry]) -> RestoredState {
state.history.push(item.to_history_item());
}
LogEntry::TurnEnd { turn_count, .. } => {
if let Some(active_turn_count) = &mut state.active_run_turn_count {
*active_turn_count += turn_count.saturating_sub(state.turn_count);
}
state.turn_count = *turn_count;
}
LogEntry::RunCompleted { interrupted, .. } => {
LogEntry::RunCompleted {
interrupted,
result,
active_run_turn_count,
..
} => {
state.last_run_interrupted = *interrupted;
if *interrupted && matches!(result, EngineResult::Paused | EngineResult::Yielded) {
// Legacy entries omit the explicit field; retain the
// Invoke/TurnEnd-derived count in that case.
if let Some(turn_count) = active_run_turn_count {
state.active_run_turn_count = Some(*turn_count);
}
} else {
state.active_run_turn_count = None;
}
}
LogEntry::RunErrored { interrupted, .. } => {
state.last_run_interrupted = *interrupted;
state.active_run_turn_count = None;
}
LogEntry::ActiveRunCheckpoint {
active_turn_count,
total_turn_count,
..
} => {
state.active_run_turn_count = Some(*active_turn_count);
state.turn_count = *total_turn_count;
state.last_run_interrupted = true;
}
LogEntry::PausedTurnAbandoned { .. } => {
state.last_run_interrupted = false;
state.active_run_turn_count = None;
}
LogEntry::ConfigChanged { config, .. } => {
state.config = config.clone();
@@ -397,6 +520,7 @@ mod tests {
ts: 3200,
interrupted: false,
result: EngineResult::Finished,
active_run_turn_count: None,
},
]);
assert_eq!(state.history.len(), 2);
@@ -695,10 +819,93 @@ mod tests {
ts: 100,
interrupted: true,
result: EngineResult::Paused,
active_run_turn_count: Some(1),
},
LogEntry::PausedTurnAbandoned { ts: 200 },
]);
assert!(!state.last_run_interrupted);
assert_eq!(state.active_run_turn_count, None);
}
#[test]
fn replay_restores_active_run_budget_across_compaction_checkpoint() {
let state = collect_state(&[
LogEntry::SegmentStart {
ts: 0,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
forked_from: None,
compacted_from: None,
},
LogEntry::ActiveRunCheckpoint {
ts: 100,
active_turn_count: 3,
total_turn_count: 9,
},
]);
assert_eq!(state.turn_count, 9);
assert_eq!(state.active_run_turn_count, Some(3));
assert!(state.last_run_interrupted);
}
#[test]
fn legacy_interrupted_run_derives_budget_from_invoke_and_turn_end() {
let entry: LogEntry = serde_json::from_value(serde_json::json!({
"kind": "run_completed",
"ts": 300,
"interrupted": true,
"result": "paused"
}))
.expect("legacy run-completed entry");
let state = collect_state(&[
LogEntry::SegmentStart {
ts: 0,
session_id: uuid::Uuid::nil(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
forked_from: None,
compacted_from: None,
},
LogEntry::Invoke {
ts: 100,
trigger: InvokeKind::UserSend,
},
LogEntry::TurnEnd {
ts: 200,
turn_count: 2,
},
entry,
]);
assert_eq!(state.active_run_turn_count, Some(2));
assert!(state.last_run_interrupted);
}
#[test]
fn non_resumable_interruption_clears_the_active_run_budget() {
let state = collect_state(&[
LogEntry::Invoke {
ts: 100,
trigger: InvokeKind::UserSend,
},
LogEntry::TurnEnd {
ts: 200,
turn_count: 2,
},
LogEntry::RunCompleted {
ts: 300,
interrupted: true,
result: EngineResult::LimitReached,
active_run_turn_count: None,
},
]);
assert!(state.last_run_interrupted);
assert_eq!(state.active_run_turn_count, None);
}
#[test]
@@ -20,7 +20,8 @@ use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
const SESSION_SCHEMA_VERSION: u32 = 1;
const SESSION_SCHEMA_VERSION: u32 = 2;
const LEGACY_SESSION_SCHEMA_VERSION: u32 = 1;
const SESSION_FILE: &str = "session.json";
const SEGMENTS_DIR: &str = "segments";
@@ -44,15 +45,22 @@ impl WorkerSessionStore {
fs::create_dir_all(root.join(SEGMENTS_DIR))?;
let session_id = match fs::read(root.join(SESSION_FILE)) {
Ok(bytes) => {
let manifest: SessionManifest = serde_json::from_slice(&bytes)?;
if manifest.schema_version != SESSION_SCHEMA_VERSION {
return Err(StoreError::Corrupt {
line: 0,
message: format!(
"unsupported Worker Session schema version {}, expected {}",
manifest.schema_version, SESSION_SCHEMA_VERSION
),
});
let mut manifest: SessionManifest = serde_json::from_slice(&bytes)?;
match manifest.schema_version {
SESSION_SCHEMA_VERSION => {}
LEGACY_SESSION_SCHEMA_VERSION => {
validate_legacy_segment_logs(&root)?;
manifest.schema_version = SESSION_SCHEMA_VERSION;
atomic_write_json(&root.join(SESSION_FILE), &manifest)?;
}
version => {
return Err(StoreError::Corrupt {
line: 0,
message: format!(
"unsupported Worker Session schema version {version}, expected {SESSION_SCHEMA_VERSION}"
),
});
}
}
Some(manifest.session_id)
}
@@ -278,6 +286,37 @@ impl Store for WorkerSessionStore {
}
}
fn validate_legacy_segment_logs(root: &Path) -> Result<(), StoreError> {
let segments = root.join(SEGMENTS_DIR);
if !segments.exists() {
return Ok(());
}
for entry in fs::read_dir(&segments)? {
let entry = entry?;
let path = entry.path();
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
continue;
};
if !name.ends_with(".jsonl") || name.ends_with(".trace.jsonl") {
continue;
}
let contents = fs::read_to_string(&path)?;
for (line_index, line) in contents.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
serde_json::from_str::<LogEntry>(line).map_err(|error| StoreError::Corrupt {
line: line_index + 1,
message: format!(
"cannot migrate legacy Worker Session log {}: {error}",
path.display()
),
})?;
}
}
Ok(())
}
fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), StoreError> {
let mut bytes = serde_json::to_vec_pretty(value)?;
bytes.push(b'\n');
@@ -405,6 +444,54 @@ mod tests {
assert_eq!(store.list_sessions().unwrap(), vec![session_id]);
}
#[test]
fn schema_v1_logs_are_validated_and_promoted_to_v2() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let segment_id = new_segment_id();
WorkerSessionStore::new(root.path())
.unwrap()
.create_segment(session_id, segment_id, &[])
.unwrap();
let manifest_path = root.path().join(SESSION_FILE);
let mut manifest: SessionManifest =
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
manifest.schema_version = LEGACY_SESSION_SCHEMA_VERSION;
atomic_write_json(&manifest_path, &manifest).unwrap();
let reopened = WorkerSessionStore::new(root.path()).unwrap();
assert_eq!(reopened.session_id().unwrap(), Some(session_id));
let migrated: SessionManifest =
serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap();
assert_eq!(migrated.schema_version, SESSION_SCHEMA_VERSION);
}
#[test]
fn schema_v1_migration_rejects_corrupt_log_before_manifest_update() {
let root = tempfile::tempdir().unwrap();
let session_id = new_session_id();
let manifest = SessionManifest {
schema_version: LEGACY_SESSION_SCHEMA_VERSION,
session_id,
};
atomic_write_json(&root.path().join(SESSION_FILE), &manifest).unwrap();
fs::create_dir_all(root.path().join(SEGMENTS_DIR)).unwrap();
fs::write(
root.path().join(SEGMENTS_DIR).join("broken.jsonl"),
"{not-json}\n",
)
.unwrap();
let error = match WorkerSessionStore::new(root.path()) {
Ok(_) => panic!("corrupt legacy Session log must reject migration"),
Err(error) => error,
};
assert!(matches!(error, StoreError::Corrupt { .. }));
let persisted: SessionManifest =
serde_json::from_slice(&fs::read(root.path().join(SESSION_FILE)).unwrap()).unwrap();
assert_eq!(persisted.schema_version, LEGACY_SESSION_SCHEMA_VERSION);
}
#[test]
fn reopen_preserves_session_and_segment_ids() {
let root = tempfile::tempdir().unwrap();
@@ -51,6 +51,7 @@ fn round_trip_write_and_read() {
ts: 3200,
interrupted: false,
result: EngineResult::Finished,
active_run_turn_count: None,
},
];
+91 -37
View File
@@ -1,12 +1,13 @@
mod common;
use std::ops::{Deref, DerefMut};
use std::sync::Arc;
use agen::Engine;
use agen::interceptor::{Interceptor, TurnEndAction};
use agen::llm_client::event::{Event, ResponseStatus, StatusEvent};
use agen::llm_client::types::{Item, RequestConfig};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use agen::{Engine, History};
use async_trait::async_trait;
use common::MockLlmClient;
use session_store::{FsStore, LogEntry, SegmentStartState, Store, collect_state};
@@ -94,15 +95,47 @@ fn make_store() -> (tempfile::TempDir, FsStore) {
(dir, store)
}
struct TestWorker {
engine: Engine<MockLlmClient>,
history: History,
}
impl TestWorker {
fn new(engine: Engine<MockLlmClient>) -> Self {
Self {
engine,
history: History::new(),
}
}
fn history(&self) -> Vec<Item> {
self.history.items_cloned()
}
}
impl Deref for TestWorker {
type Target = Engine<MockLlmClient>;
fn deref(&self) -> &Self::Target {
&self.engine
}
}
impl DerefMut for TestWorker {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.engine
}
}
/// Run a worker turn and persist via session-store functions.
/// Takes ownership of the worker (needed for lock/unlock) and returns it.
async fn run_and_persist(
worker: Engine<MockLlmClient>,
mut worker: TestWorker,
store: &FsStore,
session_id: session_store::SessionId,
segment_id: session_store::SegmentId,
input: &str,
) -> (Engine<MockLlmClient>, agen::EngineResult) {
) -> (TestWorker, agen::EngineRunExit) {
// Mirror Worker's run-entry contract: log the user input as segments
// before the worker pushes its flattened user_message; save_delta
// skips the resulting user_message item to avoid double-write.
@@ -114,41 +147,61 @@ async fn run_and_persist(
)
.unwrap();
let history_before = worker.history().len();
let history_before = worker.history.len();
let mut locked = worker.lock();
let result = locked.run(input).await;
let worker = locked.unlock();
let mut locked = worker.engine.lock(&worker.history);
let result = locked.run(&mut worker.history, input).await;
worker.engine = locked.unlock();
let new_items = &worker.history()[history_before..];
let projected = worker.history();
let new_items = &projected[history_before..];
session_store::save_delta(store, session_id, segment_id, new_items).unwrap();
session_store::save_turn_end(store, session_id, segment_id, worker.turn_count()).unwrap();
match &result {
Ok(r) => {
agen::EngineRunExit::Finished
| agen::EngineRunExit::Paused
| agen::EngineRunExit::Yielded => {
let (legacy_result, interrupted) = match &result {
agen::EngineRunExit::Finished => (agen::EngineResult::Finished, false),
agen::EngineRunExit::Paused => (agen::EngineResult::Paused, true),
agen::EngineRunExit::Yielded => (agen::EngineResult::Yielded, true),
agen::EngineRunExit::Interrupted(_) => unreachable!(),
};
session_store::save_run_completed(
store,
session_id,
segment_id,
r.clone(),
worker.last_run_interrupted(),
legacy_result,
interrupted,
worker.active_run_turn_count(),
)
.unwrap();
}
Err(e) => {
agen::EngineRunExit::Interrupted(agen::StopReason::LimitReached) => {
session_store::save_run_completed(
store,
session_id,
segment_id,
agen::EngineResult::LimitReached,
false,
worker.active_run_turn_count(),
)
.unwrap();
}
agen::EngineRunExit::Interrupted(reason) => {
session_store::save_run_errored(
store,
session_id,
segment_id,
e.to_string(),
worker.last_run_interrupted(),
format!("{reason:?}"),
true,
)
.unwrap();
}
}
let r = result.unwrap();
(worker, r)
(worker, result)
}
// =============================================================================
@@ -159,14 +212,14 @@ async fn run_and_persist(
async fn session_run_logs_entries() {
let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events());
let worker = Engine::new(client);
let worker = TestWorker::new(Engine::new(client));
let (sid, segid) = session_store::create_segment(
&store,
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: worker.history(),
history: &worker.history(),
},
)
.unwrap();
@@ -203,7 +256,7 @@ async fn session_run_logs_entries() {
async fn session_restore_round_trip() {
let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events());
let mut worker = Engine::new(client);
let mut worker = TestWorker::new(Engine::new(client));
worker.set_system_prompt("You are helpful.");
let (sid, segid) = session_store::create_segment(
@@ -211,7 +264,7 @@ async fn session_restore_round_trip() {
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: worker.history(),
history: &worker.history(),
},
)
.unwrap();
@@ -242,7 +295,7 @@ async fn session_restore_round_trip() {
async fn session_run_with_tool_call() {
let (_dir, store) = make_store();
let client = MockLlmClient::with_responses(tool_call_events());
let mut worker = Engine::new(client);
let mut worker = TestWorker::new(Engine::new(client));
worker.register_tool(weather_tool_definition());
let (sid, segid) = session_store::create_segment(
@@ -250,7 +303,7 @@ async fn session_run_with_tool_call() {
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: worker.history(),
history: &worker.history(),
},
)
.unwrap();
@@ -276,7 +329,7 @@ async fn session_resume_after_pause() {
// First run: tool call with pause policy → Paused
let client = MockLlmClient::with_responses(tool_call_events());
let mut worker = Engine::new(client);
let mut worker = TestWorker::new(Engine::new(client));
worker.register_tool(weather_tool_definition());
worker.set_interceptor(PausePolicy);
@@ -285,13 +338,13 @@ async fn session_resume_after_pause() {
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: worker.history(),
history: &worker.history(),
},
)
.unwrap();
let (_worker, result) = run_and_persist(worker, &store, sid, segid, "Weather?").await;
assert!(matches!(result, agen::EngineResult::Paused));
assert!(matches!(result, agen::EngineRunExit::Paused));
// Check RunCompleted is Paused
let entries = store.read_all(sid, segid).unwrap();
@@ -309,13 +362,14 @@ async fn session_resume_after_pause() {
// Restore state and verify
let state = session_store::restore(&store, sid, segid).unwrap();
assert!(state.last_run_interrupted);
assert_eq!(state.active_run_turn_count, Some(2));
}
#[tokio::test]
async fn session_fork_creates_new_session() {
let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events());
let mut worker = Engine::new(client);
let mut worker = TestWorker::new(Engine::new(client));
worker.set_system_prompt("System prompt");
let (sid, segid) = session_store::create_segment(
@@ -323,7 +377,7 @@ async fn session_fork_creates_new_session() {
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: worker.history(),
history: &worker.history(),
},
)
.unwrap();
@@ -336,7 +390,7 @@ async fn session_fork_creates_new_session() {
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: worker.history(),
history: &worker.history(),
},
)
.unwrap();
@@ -357,14 +411,14 @@ async fn session_fork_creates_new_session() {
async fn session_fork_at_truncates_within_session() {
let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events());
let worker = Engine::new(client);
let worker = TestWorker::new(Engine::new(client));
let (sid, segid) = session_store::create_segment(
&store,
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: worker.history(),
history: &worker.history(),
},
)
.unwrap();
@@ -402,14 +456,14 @@ async fn session_fork_at_truncates_within_session() {
async fn session_config_changed_logged() {
let (_dir, store) = make_store();
let client = MockLlmClient::new(vec![]);
let mut worker = Engine::new(client);
let mut worker = TestWorker::new(Engine::new(client));
let (sid, segid) = session_store::create_segment(
&store,
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: worker.history(),
history: &worker.history(),
},
)
.unwrap();
@@ -435,14 +489,14 @@ async fn session_auto_forks_on_conflict() {
// Create a segment
let client_a = MockLlmClient::new(simple_text_events());
let worker_a = Engine::new(client_a);
let worker_a = TestWorker::new(Engine::new(client_a));
let (sid, original_segid) = session_store::create_segment(
&store,
SegmentStartState {
system_prompt: worker_a.get_system_prompt(),
config: worker_a.request_config(),
history: worker_a.history(),
history: &worker_a.history(),
},
)
.unwrap();
@@ -468,7 +522,7 @@ async fn session_auto_forks_on_conflict() {
SegmentStartState {
system_prompt: worker_a.get_system_prompt(),
config: worker_a.request_config(),
history: worker_a.history(),
history: &worker_a.history(),
},
)
.unwrap();
@@ -520,14 +574,14 @@ async fn session_auto_forks_on_conflict() {
async fn nested_past_fork_leaves_ancestors_immutable() {
let (_dir, store) = make_store();
let client = MockLlmClient::new(simple_text_events());
let worker = Engine::new(client);
let worker = TestWorker::new(Engine::new(client));
let (sid, root_segid) = session_store::create_segment(
&store,
SegmentStartState {
system_prompt: worker.get_system_prompt(),
config: worker.request_config(),
history: worker.history(),
history: &worker.history(),
},
)
.unwrap();
+150 -16
View File
@@ -410,7 +410,7 @@ struct TicketCreateParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketEditItemParams {
/// Ticket id.
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String,
/// Optional replacement title.
#[serde(default)]
@@ -539,7 +539,7 @@ impl QueryTicketParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ShowTicketParams {
/// Ticket id. Exactly one of `id` or `query` must be provided.
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. Exactly one of `id` or `query` must be provided.
#[serde(default)]
id: Option<String>,
/// Exact ticket id query. Exactly one of `id` or `query` must be provided.
@@ -558,7 +558,7 @@ struct ShowTicketParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketThreadEventParams {
/// Ticket id.
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String,
/// Markdown event body.
body: String,
@@ -566,7 +566,7 @@ struct TicketThreadEventParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketMarkReadyParams {
/// Ticket id.
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String,
/// Optional reason attached to the state_changed event.
#[serde(default)]
@@ -575,7 +575,7 @@ struct TicketMarkReadyParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketIntakeReadyParams {
/// Ticket id.
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String,
/// Concise bounded intake summary appended before the ready transition.
intake_summary: String,
@@ -586,13 +586,13 @@ struct TicketIntakeReadyParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketQueueParams {
/// Ticket id.
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketWorkflowStateParams {
/// Ticket id.
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String,
/// Expected current state. The backend rejects stale transitions.
from: TicketWorkflowStateParam,
@@ -606,7 +606,7 @@ struct TicketWorkflowStateParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketCloseParams {
/// Ticket id.
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String,
/// Markdown resolution written to resolution.md and thread.md.
resolution: String,
@@ -614,7 +614,7 @@ struct TicketCloseParams {
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct TicketDependencyCheckParams {
/// Ticket id.
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
ticket: String,
}
@@ -646,7 +646,7 @@ struct TicketRelationRecordParams {
ticket: String,
/// Forward relation kind: depends_on, blocks, related, supersedes, or duplicate_of.
kind: TicketRelationKindParam,
/// Target canonical Ticket id. Title/slug words are not accepted as relation authority.
/// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
target: String,
/// Optional bounded rationale/note.
#[serde(default)]
@@ -659,7 +659,7 @@ struct TicketRelationRemoveParams {
ticket: String,
/// Forward relation kind to remove.
kind: TicketRelationKindParam,
/// Target canonical Ticket id.
/// Target Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
target: String,
}
@@ -1223,10 +1223,17 @@ impl Tool for TicketQueueTool {
) -> Result<ToolOutput, ToolError> {
let params: TicketQueueParams = parse_input("TicketQueue", input_json)?;
let queued_by = default_author();
let outcome = self
let mut outcome = self
.backend
.queue_ready(TicketIdOrSlug::Query(params.ticket.clone()), &queued_by)
.map_err(|error| backend_error("TicketQueue", error))?;
outcome.requested_ticket =
model_ticket_reference(&self.backend, &outcome.requested_ticket, "TicketQueue")?;
outcome.queued_tickets = outcome
.queued_tickets
.into_iter()
.map(|ticket| model_ticket_reference(&self.backend, &ticket, "TicketQueue"))
.collect::<Result<Vec<_>, _>>()?;
Ok(json_output(
format!(
"Queued {} ticket(s) for Orchestrator",
@@ -1264,15 +1271,17 @@ impl Tool for TicketWorkflowStateTool {
self.backend
.set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change)
.map_err(|error| backend_error("TicketWorkflowState", error))?;
let ticket_ref =
model_ticket_reference(&self.backend, &params.ticket, "TicketWorkflowState")?;
Ok(json_output(
format!(
"Transitioned ticket {} state {} -> {}",
params.ticket,
ticket_ref,
from.as_str(),
to.as_str()
),
json!({
"ticket": params.ticket,
"ticket": ticket_ref,
"from": from.as_str(),
"to": to.as_str(),
"state": to.as_str(),
@@ -1296,9 +1305,10 @@ impl Tool for TicketCloseTool {
MarkdownText::new(params.resolution),
)
.map_err(|error| backend_error("TicketClose", error))?;
let ticket_ref = model_ticket_reference(&self.backend, &params.ticket, "TicketClose")?;
Ok(json_output(
format!("Closed ticket {}", params.ticket),
json!({ "ticket": params.ticket, "state": "closed", "ok": true }),
format!("Closed ticket {ticket_ref}"),
json!({ "ticket": ticket_ref, "state": "closed", "ok": true }),
))
}
}
@@ -1525,6 +1535,29 @@ impl Tool for TicketDependencyCheckTool {
}
}
fn model_ticket_reference(
backend: &TicketToolBackend,
reference: &str,
tool_name: &str,
) -> Result<String, ToolError> {
let ticket = backend
.show(TicketIdOrSlug::Id(reference.to_string()))
.map_err(|error| backend_error(tool_name, error))?;
match ticket.meta.resource_key {
Some(resource_key) if is_canonical_ticket_resource_key(&resource_key) => Ok(resource_key),
Some(_) => Err(ToolError::ExecutionFailed(format!(
"{tool_name} failed: required Ticket key is unavailable"
))),
None => Ok(ticket.meta.id),
}
}
fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
resource_key.strip_prefix("T-").is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn parse_input<T: for<'de> Deserialize<'de>>(tool: &str, input_json: &str) -> Result<T, ToolError> {
serde_json::from_str(input_json)
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
@@ -1922,6 +1955,12 @@ mod tests {
.with_target_authority(Arc::new(TestTargetAuthority))
}
fn sqlite_backend(temp: &TempDir) -> crate::SqliteTicketBackend {
crate::SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace")
.unwrap()
.with_target_authority(Arc::new(TestTargetAuthority))
}
fn tool(definition: ToolDefinition) -> Arc<dyn Tool> {
let (_, tool) = definition();
tool
@@ -2549,6 +2588,101 @@ mod tests {
);
}
#[tokio::test]
async fn queue_workflow_and_close_project_internal_inputs_to_ticket_keys() {
let temp = TempDir::new().unwrap();
let inner = sqlite_backend(&temp);
let mut dependency_input = NewTicket::new("Dependency");
dependency_input.repository_id = Some("main".to_string());
let dependency = inner.create(dependency_input).unwrap();
let mut target_input = NewTicket::new("Target");
target_input.repository_id = Some("main".to_string());
let target = inner.create(target_input).unwrap();
inner
.add_ticket_relation(
TicketIdOrSlug::Id(target.id.clone()),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: dependency.id.clone(),
note: None,
author: None,
},
)
.unwrap();
for id in [&dependency.id, &target.id] {
inner
.mark_ready(
TicketIdOrSlug::Id(id.clone()),
TicketMarkReady {
operation_key: format!("ready-{id}"),
reason: None,
author: None,
intake_summary: None,
},
)
.unwrap();
}
let target_key = target.resource_key.clone().unwrap();
let dependency_key = dependency.resource_key.clone().unwrap();
let backend = inner;
let queue = tool_by_name(TicketToolBackend::new(backend.clone()), "TicketQueue");
let workflow = tool_by_name(
TicketToolBackend::new(backend.clone()),
"TicketWorkflowState",
);
let close = tool_by_name(TicketToolBackend::new(backend), "TicketClose");
let queued = queue
.execute(
&json!({"ticket": target.id.clone()}).to_string(),
Default::default(),
)
.await
.unwrap();
assert!(queued.summary.contains("2 ticket(s)"));
let queued_content = queued.content.unwrap();
assert!(queued_content.contains(&target_key));
assert!(queued_content.contains(&dependency_key));
assert!(!queued_content.contains(&target.id));
assert!(!queued_content.contains(&dependency.id));
for (from, to) in [("queued", "inprogress"), ("inprogress", "done")] {
let transitioned = workflow
.execute(
&json!({
"ticket": target.id.clone(),
"from": from,
"to": to,
"reason": "test_transition",
"body": "transitioned",
"author": "tester"
})
.to_string(),
Default::default(),
)
.await
.unwrap();
assert!(transitioned.summary.contains(&target_key));
assert!(!transitioned.summary.contains(&target.id));
let content = transitioned.content.unwrap();
assert!(content.contains(&target_key));
assert!(!content.contains(&target.id));
}
let closed = close
.execute(
&json!({"ticket": target.id.clone(), "resolution": "Done"}).to_string(),
Default::default(),
)
.await
.unwrap();
assert!(closed.summary.contains(&target_key));
assert!(!closed.summary.contains(&target.id));
let content = closed.content.unwrap();
assert!(content.contains(&target_key));
assert!(!content.contains(&target.id));
}
#[tokio::test]
async fn ticket_workflow_tools_mark_ready_and_transition_state() {
let temp = TempDir::new().unwrap();
+159 -14
View File
@@ -1,5 +1,6 @@
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait;
@@ -20,21 +21,65 @@ struct BashParams {
pub(crate) struct BashTool {
session: WorkdirSessionHandle,
state: Arc<Mutex<BashExecutionState>>,
}
#[derive(Clone)]
struct ActiveCommand {
call_id: String,
execution_nonce: u64,
handle: CommandHandle,
}
#[derive(Default)]
struct BashExecutionState {
active: HashMap<String, ActiveCommand>,
cancellation_requested: HashSet<String>,
legacy_cancellation_requested: HashSet<String>,
next_execution_nonce: u64,
}
struct CommandGuard {
session: WorkdirSessionHandle,
state: Arc<Mutex<BashExecutionState>>,
execution_id: String,
execution_nonce: u64,
handle: Option<CommandHandle>,
}
impl Drop for CommandGuard {
fn drop(&mut self) {
if let Some(handle) = self.handle.take() {
let workdir = self.session.clone();
tokio::spawn(async move {
let _ = workdir.cancel_command(handle).await;
});
}
let Some(handle) = self.handle.take() else {
return;
};
let workdir = self.session.clone();
let state = Arc::clone(&self.state);
let execution_id = self.execution_id.clone();
let execution_nonce = self.execution_nonce;
// A dropped provider future is not terminal confirmation. Keep the live
// execution registered until cleanup has both requested cancellation and
// observed terminal command output, so cancellation/session teardown
// cannot race with an apparently empty registry.
tokio::spawn(async move {
let _ = workdir.cancel_command(handle.clone()).await;
let _ = workdir
.command_output(CommandOutputRequest {
handle,
cursor: 0,
limit: INLINE_BYTE_BUDGET,
wait: true,
})
.await;
let mut state = state.lock().unwrap();
if state
.active
.get(&execution_id)
.is_some_and(|active| active.execution_nonce == execution_nonce)
{
state.active.remove(&execution_id);
state.cancellation_requested.remove(&execution_id);
}
});
}
}
@@ -52,20 +97,50 @@ impl Tool for BashTool {
.unwrap_or(DEFAULT_TIMEOUT_SECS)
.clamp(1, MAX_TIMEOUT_SECS);
let cmd_summary = truncate_for_summary(&params.command);
let execution_id = ctx.execution_id();
let call_id = ctx.call_id;
let execution_nonce = {
let mut state = self.state.lock().unwrap();
state.next_execution_nonce = state.next_execution_nonce.wrapping_add(1);
state.next_execution_nonce
};
let mut guard = CommandGuard {
session: self.session.clone(),
state: self.state.clone(),
execution_id: execution_id.clone(),
execution_nonce,
handle: None,
};
let handle = self
.session
.start_command(CommandRequest {
command: params.command,
timeout_secs,
output_limit: INLINE_BYTE_BUDGET,
tool_call_id: Some(ctx.call_id),
tool_call_id: Some(call_id.clone()),
})
.await
.map_err(crate::ToolsError::from)?;
let mut guard = CommandGuard {
session: self.session.clone(),
handle: Some(handle.clone()),
let cancel_after_start = {
let mut state = self.state.lock().unwrap();
state.active.insert(
execution_id.clone(),
ActiveCommand {
call_id: call_id.clone(),
execution_nonce,
handle: handle.clone(),
},
);
state.cancellation_requested.contains(&execution_id)
|| state.legacy_cancellation_requested.contains(&call_id)
};
guard.handle = Some(handle.clone());
if cancel_after_start {
self.session
.cancel_command(handle.clone())
.await
.map_err(crate::ToolsError::from)?;
}
let output = self
.session
.command_output(CommandOutputRequest {
@@ -76,9 +151,27 @@ impl Tool for BashTool {
})
.await
.map_err(crate::ToolsError::from)?;
let cancellation_requested = {
let mut state = self.state.lock().unwrap();
let owns_registration = state
.active
.get(&execution_id)
.is_some_and(|active| active.execution_nonce == execution_nonce);
let exact = if owns_registration {
state.active.remove(&execution_id);
state.cancellation_requested.remove(&execution_id)
} else {
false
};
let legacy = state.legacy_cancellation_requested.remove(&call_id);
exact || legacy
};
guard.handle = None;
let summary = if output.timed_out {
let timed_out = output.timed_out;
let summary = if cancellation_requested {
format!("$ {cmd_summary} (cancelled)")
} else if output.timed_out {
format!("$ {cmd_summary} (timed out after {timeout_secs}s)")
} else {
match output.exit_code {
@@ -97,11 +190,62 @@ impl Tool for BashTool {
} else {
Some(output.content)
};
Ok(ToolOutput {
let output = ToolOutput {
summary,
content,
attachments: Vec::new(),
})
};
if cancellation_requested {
Err(ToolError::Cancelled(output))
} else if timed_out {
Err(ToolError::Interrupted(output))
} else {
Ok(output)
}
}
async fn cancel(&self, call_id: &str) -> Result<(), ToolError> {
let handles = {
let mut state = self.state.lock().unwrap();
state
.legacy_cancellation_requested
.insert(call_id.to_string());
state
.active
.values()
.filter(|active| active.call_id == call_id)
.map(|active| active.handle.clone())
.collect::<Vec<_>>()
};
for handle in handles {
self.session
.cancel_command(handle)
.await
.map_err(crate::ToolsError::from)?;
}
Ok(())
}
async fn cancel_execution(
&self,
ctx: &agen::tool::ToolExecutionContext,
) -> Result<(), ToolError> {
let execution_id = ctx.execution_id();
let handle = {
let mut state = self.state.lock().unwrap();
state.cancellation_requested.insert(execution_id.clone());
state
.active
.get(&execution_id)
.map(|active| active.handle.clone())
};
if let Some(handle) = handle {
self.session
.cancel_command(handle)
.await
.map_err(crate::ToolsError::from)?;
}
Ok(())
}
}
@@ -123,6 +267,7 @@ pub fn bash_tool(session: WorkdirSessionHandle, _output_dir: PathBuf) -> ToolDef
.input_schema(serde_json::to_value(schema).expect("Bash schema serialization"));
let tool: Arc<dyn Tool> = Arc::new(BashTool {
session: session.clone(),
state: Arc::new(Mutex::new(BashExecutionState::default())),
});
(meta, tool)
})
+1 -1
View File
@@ -129,7 +129,7 @@ pub fn grep_tool(session: WorkdirSessionHandle) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(GrepParams);
let meta = ToolMeta::new("Grep")
.description("Search Workdir file contents with a regex. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
.description("Search Workdir file contents with a regex. Content results group lines by file; `>` marks matching lines and unmarked lines are context. Glob/Grep traversal executes inside the WorkdirSession provider. Results are bounded and Workdir-relative.")
.input_schema(serde_json::to_value(schema).expect("Grep schema serialization"));
let tool: Arc<dyn Tool> = Arc::new(GrepTool {
session: session.clone(),
+83 -1
View File
@@ -7,7 +7,10 @@
use std::path::Path;
use std::sync::Arc;
use agen::tool::{Tool, ToolDefinition, ToolMeta};
use agen::tool::{
Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolExecutionHandle,
ToolExecutionTerminal, ToolMeta,
};
use manifest::{Permission, Scope, ScopeConfig, ScopeRule};
use serde_json::json;
use tempfile::TempDir;
@@ -401,5 +404,84 @@ async fn bash_provider_output_does_not_expose_internal_paths() {
assert_eq!(std::fs::read_dir(spill.path()).unwrap().count(), 0);
}
#[tokio::test]
async fn bash_cancellation_returns_bounded_progress_as_terminal_output() {
let (dir, _spill, reg) = setup();
let marker = dir.path().join("must-not-run-after-cancel");
let command = format!(
"printf 'before\\n'; printf 'err-before\\n' >&2; sleep 1; touch {}; printf 'after\\n'",
marker.display()
);
let input = serde_json::to_string(&json!({ "command": command })).unwrap();
let context = ToolExecutionContext::new("call-heavy", "attempt-heavy", 0);
let bash = reg.get("Bash");
let executing = bash.clone();
let execution_context = context.clone();
let execution = tokio::spawn(async move { executing.execute(&input, execution_context).await });
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
bash.cancel_execution(&context)
.await
.expect("signal exact execution cancellation");
let error = tokio::time::timeout(std::time::Duration::from_secs(2), execution)
.await
.expect("cancelled Bash should terminate inside the Engine grace budget")
.expect("Bash task join");
let ToolError::Cancelled(output) = error.expect_err("cancelled command is non-success") else {
panic!("expected typed cancellation result");
};
let content = output.content.expect("bounded progress output");
assert!(
content.contains("before"),
"missing pre-cancel stdout: {content}"
);
assert!(
content.contains("err-before"),
"missing pre-cancel stderr: {content}"
);
assert!(
!content.contains("after"),
"post-cancel output leaked: {content}"
);
assert!(content.len() <= 16 * 1024, "output must remain bounded");
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
assert!(
!marker.exists(),
"the cancelled command continued executing after terminal confirmation"
);
}
#[tokio::test]
async fn bash_force_close_cleanup_stops_command_and_keeps_session_reusable() {
let (dir, _spill, reg) = setup();
let marker = dir.path().join("must-not-survive-force-close");
let command = format!("sleep 1; touch {}", marker.display());
let input = serde_json::to_string(&json!({ "command": command })).unwrap();
let bash = reg.get("Bash");
let context = ToolExecutionContext::new("call-force", "attempt-force", 0);
let (handle, terminal) = ToolExecutionHandle::start(bash.clone(), input, context);
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
handle.force_close();
assert!(matches!(
terminal.await,
ToolExecutionTerminal::OutcomeUnknown
));
tokio::time::sleep(std::time::Duration::from_millis(1_100)).await;
assert!(
!marker.exists(),
"CommandGuard cleanup allowed a force-closed command to continue"
);
let output = bash
.execute(r#"{"command":"printf 'reused'"}"#, Default::default())
.await
.expect("workdir session remains reusable after cleanup");
assert_eq!(output.content.as_deref(), Some("reused"));
}
// Sanity: unused Path import guard
const _: fn() -> &'static Path = || Path::new("/");
+52 -16
View File
@@ -1244,6 +1244,7 @@ impl App {
id,
summary,
output,
disposition: _,
is_error,
} => {
self.latest_llm_wait_event = None;
@@ -1342,13 +1343,20 @@ impl App {
}
}
}
Event::CompactStart => {
self.blocks.push(Block::Compact(CompactEvent::Streaming {
started_at: Instant::now(),
}));
Event::CompactStart { .. } => {
if self.last_streaming_compact_mut().is_none() {
self.blocks.push(Block::Compact(CompactEvent::Streaming {
started_at: Instant::now(),
}));
}
}
Event::CompactDone { new_segment_id } => {
Event::CompactDone { lifecycle } => {
self.session_context_tokens = 0;
let new_segment_id = lifecycle
.new_segment_id
.as_deref()
.and_then(|value| uuid::Uuid::parse_str(value).ok())
.unwrap_or_default();
if let Some(evt) = self.last_streaming_compact_mut() {
let elapsed_secs = match evt {
CompactEvent::Streaming { started_at } => {
@@ -1367,7 +1375,10 @@ impl App {
}));
}
}
Event::CompactFailed { error } => {
Event::CompactFailed { lifecycle } => {
let error = lifecycle
.error
.unwrap_or_else(|| "compaction failed".to_string());
if let Some(evt) = self.last_streaming_compact_mut() {
let elapsed_secs = match evt {
CompactEvent::Streaming { started_at } => {
@@ -2486,7 +2497,7 @@ fn event_is_stale_after_rewind(event: &Event) -> bool {
event,
Event::Alert(_)
| Event::MemoryWorker(_)
| Event::CompactStart
| Event::CompactStart { .. }
| Event::CompactDone { .. }
| Event::CompactFailed { .. }
| Event::SegmentRotated { .. }
@@ -4076,13 +4087,34 @@ mod completion_flow_tests {
}
}
fn test_compaction_lifecycle(
state: protocol::CompactionLifecycleState,
) -> protocol::CompactionLifecycle {
protocol::CompactionLifecycle {
schema_version: 2,
compaction_id: "compaction-test".into(),
revision: 1,
internal_worker: None,
state,
started_at_ms: 1,
ended_at_ms: None,
summary: None,
error: None,
new_segment_id: None,
}
}
#[test]
fn compact_done_replaces_live_block() {
let mut app = App::new("test".into());
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
app.handle_worker_event(Event::CompactStart);
app.handle_worker_event(Event::CompactDone { new_segment_id: id });
app.handle_worker_event(Event::CompactStart {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
});
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
lifecycle.new_segment_id = Some(id.to_string());
app.handle_worker_event(Event::CompactDone { lifecycle });
assert_eq!(compact_block_count(&app), 1);
assert!(matches!(
@@ -4098,10 +4130,12 @@ mod completion_flow_tests {
fn compact_failed_replaces_live_block() {
let mut app = App::new("test".into());
app.handle_worker_event(Event::CompactStart);
app.handle_worker_event(Event::CompactFailed {
error: "provider 429".into(),
app.handle_worker_event(Event::CompactStart {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
});
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
lifecycle.error = Some("provider 429".into());
app.handle_worker_event(Event::CompactFailed { lifecycle });
assert_eq!(compact_block_count(&app), 1);
assert!(matches!(
@@ -4117,7 +4151,9 @@ mod completion_flow_tests {
fn shutdown_marks_live_compact_incomplete() {
let mut app = App::new("test".into());
app.handle_worker_event(Event::CompactStart);
app.handle_worker_event(Event::CompactStart {
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
});
app.handle_worker_event(Event::Shutdown);
assert!(app.quit);
@@ -4208,9 +4244,9 @@ mod completion_flow_tests {
let mut app = App::new("test".into());
app.session_context_tokens = 42_000;
app.handle_worker_event(Event::CompactDone {
new_segment_id: uuid::Uuid::nil(),
});
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
lifecycle.new_segment_id = Some(uuid::Uuid::nil().to_string());
app.handle_worker_event(Event::CompactDone { lifecycle });
assert_eq!(app.session_context_tokens, 0);
}
+1 -1
View File
@@ -1016,7 +1016,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
app.clear_queued_inputs();
Some(Method::Cancel)
}
WorkerStatus::Idle => Some(Method::Shutdown),
WorkerStatus::Idle | WorkerStatus::Stopped => Some(Method::Shutdown),
}),
KeyCode::Char('d') if ctrl => {
app.quit = true;
+87 -90
View File
@@ -29,7 +29,6 @@ use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Widget, Wrap};
use serde::Serialize;
use session_store::FsStore;
use session_store::FsWorkerStore;
use ticket::config::{GitBranchName, TicketConfig, TicketOrchestrationConfig};
@@ -70,10 +69,6 @@ use render::{PanelListRow, row_hit_boxes};
const MAX_ENTRIES: usize = 50;
const CLOSED_VISIBLE_ROWS: usize = 3;
const ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT: &str = "panel.orchestrator_idle_queue_notice";
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS: usize = 6;
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS: usize = 120;
const ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS: usize = 2_400;
const SOCKET_OP_TIMEOUT: Duration = Duration::from_secs(3);
const DASHBOARD_POLL_INTERVAL: Duration = Duration::from_millis(1_500);
const TERMINAL_EVENT_POLL_INTERVAL: Duration = Duration::from_millis(100);
@@ -581,6 +576,7 @@ pub(crate) enum IntakeRegistryUpdate {
pub(crate) struct ReadyTicketPlanningReturnRequest {
workspace_root: PathBuf,
ticket_id: String,
ticket_key: String,
user_instruction: String,
followup: ReadyTicketPlanningReturnFollowup,
}
@@ -910,6 +906,7 @@ struct OrchestratorActiveWorkItem {
#[derive(Debug, Clone, PartialEq, Eq)]
struct OrchestratorQueuedWorkItem {
id: String,
resource_key: Option<String>,
title: String,
classification: OrchestratorQueuedClassification,
waiting_reason: Option<String>,
@@ -974,22 +971,6 @@ impl OrchestratorQueueAttentionNoticeResult {
}
}
#[derive(Debug, Serialize)]
struct OrchestratorQueueTemplateContext {
workspace: String,
actionable_tickets: Vec<OrchestratorQueueTemplateTicket>,
waiting_tickets: Vec<OrchestratorQueueTemplateTicket>,
omitted_ticket_count: usize,
}
#[derive(Debug, Serialize)]
struct OrchestratorQueueTemplateTicket {
id: String,
title: String,
classification: &'static str,
waiting_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PanelRowHitBox {
rect: Rect,
@@ -1325,7 +1306,16 @@ impl DashboardApp {
if self.orchestrator_work_set.is_empty() {
self.refresh_orchestrator_work_set();
}
let notice = orchestrator_queue_attention_notice(&self.panel, &self.orchestrator_work_set)?;
let notice = match orchestrator_queue_attention_notice(&self.orchestrator_work_set) {
Ok(Some(notice)) => notice,
Ok(None) => return None,
Err(error) => {
self.notice = Some(format!(
"Orchestrator queued-work attention not delivered: {error}"
));
return None;
}
};
if self
.orchestrator_queue_attention
.as_ref()
@@ -2042,11 +2032,18 @@ impl DashboardApp {
return None;
};
let ticket_id = ticket.id.clone();
let ticket_key = match required_ticket_handoff_key(ticket.resource_key.as_deref()) {
Ok(ticket_key) => ticket_key.to_string(),
Err(error) => {
self.notice = Some(error);
return None;
}
};
let mut context =
TicketRoleLaunchContext::new(current_workspace_root(), TicketRole::Intake);
context.ticket = Some(TicketRef::id(ticket_id.clone()));
context.user_instruction = Some(format!(
"Continue Intake for existing Ticket {ticket_id}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions."
"Continue Intake for existing Ticket {ticket_key}. Do not create a duplicate Ticket unless the user explicitly requests one. Read ShowTicket body/thread/artifacts before making routing or requirements decisions."
));
let store = match PanelRegistryStore::default_for_workspace(&context.workspace_root) {
Ok(store) => store,
@@ -2059,7 +2056,7 @@ impl DashboardApp {
Ok(Some(claim)) => {
let status = local_claim_status_for_pod(&claim.worker_name, &self.list);
self.notice = Some(existing_ticket_claim_notice(
&ticket_id,
&ticket_key,
&claim.worker_name,
status,
));
@@ -2087,7 +2084,7 @@ impl DashboardApp {
self.sending = true;
self.notice = Some(format!(
"Launching Ticket Intake for {} as {}…",
ticket_id, planned.worker_name
ticket_key, planned.worker_name
));
Some(IntakeLaunchRequest {
context,
@@ -2158,10 +2155,17 @@ impl DashboardApp {
return None;
};
let ticket_id = ticket.id.clone();
let ticket_key = match required_ticket_handoff_key(ticket.resource_key.as_deref()) {
Ok(ticket_key) => ticket_key.to_string(),
Err(error) => {
self.notice = Some(error);
return None;
}
};
if ticket.workflow_state != TicketWorkflowState::Ready {
self.notice = Some(format!(
"Ticket {} is {}; expected ready before returning to planning.",
ticket_id,
ticket_key,
ticket.workflow_state.as_str()
));
return None;
@@ -2213,7 +2217,7 @@ impl DashboardApp {
TicketRoleLaunchContext::new(workspace_root.clone(), TicketRole::Intake);
context.ticket = Some(TicketRef::id(ticket_id.clone()));
context.user_instruction = Some(build_ready_ticket_refinement_launch_instruction(
&ticket_id,
&ticket_key,
&user_instruction,
));
let peer_registration = self.prepare_intake_peer_registration(&mut context);
@@ -2237,11 +2241,12 @@ impl DashboardApp {
self.sending = true;
self.notice = Some(format!(
"Returning ready Ticket {} to planning for refinement…",
ticket_id
ticket_key
));
Some(ReadyTicketPlanningReturnRequest {
workspace_root,
ticket_id,
ticket_key,
user_instruction,
followup,
})
@@ -3645,6 +3650,7 @@ fn derive_orchestrator_work_set(
};
Some(OrchestratorQueuedWorkItem {
id: ticket.id.clone(),
resource_key: ticket.resource_key.clone(),
title: ticket.title.clone(),
classification,
waiting_reason,
@@ -3728,72 +3734,46 @@ fn orchestrator_work_set_fingerprint(
}
fn orchestrator_queue_attention_notice(
panel: &WorkspacePanelViewModel,
work_set: &OrchestratorWorkSet,
) -> Option<OrchestratorQueueAttentionNotice> {
) -> Result<Option<OrchestratorQueueAttentionNotice>, &'static str> {
if work_set.has_active_inprogress() {
return None;
return Ok(None);
}
let actionable = work_set.actionable_queued();
if actionable.is_empty() {
return None;
return Ok(None);
}
let waiting = work_set
.queued
.iter()
.filter(|item| item.waiting_reason.is_some())
.collect::<Vec<_>>();
let ticket_count = actionable.len() + waiting.len();
let actionable_tickets = actionable
.iter()
.take(ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS)
.map(|item| orchestrator_queue_template_ticket(item))
.collect::<Vec<_>>();
let remaining_capacity =
ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS.saturating_sub(actionable_tickets.len());
let waiting_tickets = waiting
.iter()
.take(remaining_capacity)
.map(|item| orchestrator_queue_template_ticket(item))
.collect::<Vec<_>>();
let rendered =
render_orchestrator_queue_attention_template(&OrchestratorQueueTemplateContext {
workspace: bounded_progress_text(
&panel.header.workspace_label,
ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS,
),
actionable_tickets,
waiting_tickets,
omitted_ticket_count: ticket_count
.saturating_sub(ORCHESTRATOR_QUEUE_ATTENTION_MAX_TICKETS),
.filter(|item| item.waiting_reason.is_some());
let tickets = actionable
.into_iter()
.chain(waiting)
.map(|item| {
let resource_key = item
.resource_key
.clone()
.ok_or("queued Ticket is missing its required resource key")?;
worker::OrchestratorQueueAttentionTicket::new(resource_key, item.title.clone())
.map_err(|_| "queued Ticket has an invalid resource key")
})
.ok()?;
let message = bounded_progress_text(&rendered, ORCHESTRATOR_QUEUE_ATTENTION_MAX_MESSAGE_CHARS);
.collect::<Result<Vec<_>, _>>()?;
let context = worker::OrchestratorQueueAttentionContext::new(tickets);
let message = render_orchestrator_queue_attention_template(&context)
.map_err(|_| "queued-work attention prompt rendering failed")?;
let fingerprint = format!("idle-queue:{}", work_set.fingerprint);
Some(OrchestratorQueueAttentionNotice {
Ok(Some(OrchestratorQueueAttentionNotice {
message,
fingerprint,
})
}
fn orchestrator_queue_template_ticket(
item: &&OrchestratorQueuedWorkItem,
) -> OrchestratorQueueTemplateTicket {
OrchestratorQueueTemplateTicket {
id: bounded_progress_text(&item.id, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS),
title: bounded_progress_text(&item.title, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS),
classification: item.classification.as_str(),
waiting_reason: item.waiting_reason.as_ref().map(|reason| {
bounded_progress_text(reason, ORCHESTRATOR_QUEUE_ATTENTION_MAX_TEXT_CHARS)
}),
}
}))
}
fn render_orchestrator_queue_attention_template(
context: &OrchestratorQueueTemplateContext,
context: &worker::OrchestratorQueueAttentionContext,
) -> Result<String, worker::CatalogError> {
worker::PromptCatalog::builtins_only()?
.render_serializable(ORCHESTRATOR_IDLE_QUEUE_NOTICE_PROMPT, context)
.orchestrator_queue_attention(worker::OrchestratorQueueAttentionPrompt::Tui, context)
}
fn orchestrator_work_set_detail(
@@ -3918,21 +3898,35 @@ fn bounded_refinement_instruction(input: &str) -> String {
.to_string()
}
fn build_ready_ticket_refinement_thread_body(ticket_id: &str, instruction: &str) -> String {
fn required_ticket_handoff_key(resource_key: Option<&str>) -> Result<&str, String> {
let resource_key = resource_key.ok_or_else(|| {
"Ticket handoff is unavailable because the canonical T-* resource key is missing. Refresh the panel and retry."
.to_string()
})?;
let sequence = resource_key.strip_prefix("T-").filter(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
});
sequence.map(|_| resource_key).ok_or_else(|| {
"Ticket handoff is unavailable because the canonical T-* resource key is invalid. Refresh the panel and retry."
.to_string()
})
}
fn build_ready_ticket_refinement_thread_body(ticket_key: &str, instruction: &str) -> String {
format!(
"Panel returned ready Ticket {ticket_id} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n"
"Panel returned ready Ticket {ticket_key} to planning for requirements sync. This is not Queue routing and must not start implementation.\n\n## User refinement instruction\n\n{instruction}\n"
)
}
fn build_ready_ticket_refinement_launch_instruction(ticket_id: &str, instruction: &str) -> String {
fn build_ready_ticket_refinement_launch_instruction(ticket_key: &str, instruction: &str) -> String {
format!(
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_id}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
"Continue Ticket Intake / requirements sync for existing Ticket {ticket_key}. The Panel has returned the Ticket from ready to planning; do not queue the Ticket, do not route implementation, and do not create a duplicate unless the user explicitly asks for one. Read ShowTicket body/thread/artifacts before making requirements or readiness decisions.\n\nUser refinement instruction:\n\n{instruction}"
)
}
fn build_ready_ticket_refinement_notify(ticket_id: &str, instruction: &str) -> String {
fn build_ready_ticket_refinement_notify(ticket_key: &str, instruction: &str) -> String {
format!(
"Ticket {ticket_id} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}"
"Ticket {ticket_key} was returned from ready to planning from the Panel for requirements sync. Continue Intake/refinement only; do not Queue or route implementation. Read the Ticket thread for the recorded state change and user instruction.\n\nUser refinement instruction:\n\n{instruction}"
)
}
@@ -3961,10 +3955,12 @@ async fn dispatch_ready_ticket_planning_return(
let ticket = backend
.show(id.clone())
.map_err(|error| TicketActionError::Ticket(error.to_string()))?;
let ticket_key =
required_ticket_handoff_key(Some(&request.ticket_key)).map_err(TicketActionError::Stale)?;
if ticket.meta.workflow_state != TicketWorkflowState::Ready {
return Err(TicketActionError::Stale(format!(
"Ticket {} is {}; expected ready before returning it to planning. Refresh the panel and retry if appropriate.",
ticket.meta.id,
ticket_key,
ticket.meta.workflow_state.as_str()
)));
}
@@ -3973,7 +3969,7 @@ async fn dispatch_ready_ticket_planning_return(
TicketWorkflowState::Planning.as_str(),
"panel_return_to_planning",
MarkdownText::from(build_ready_ticket_refinement_thread_body(
&ticket.meta.id,
ticket_key,
&request.user_instruction,
)),
);
@@ -3987,7 +3983,7 @@ async fn dispatch_ready_ticket_planning_return(
ReadyTicketPlanningReturnOutcome {
notice: format!(
"Ticket {} returned to planning for refinement; launching Ticket Intake…",
ticket.meta.id
ticket_key
),
followup: ReadyTicketPlanningReturnAfterMutation::LaunchIntake(request),
}
@@ -3997,19 +3993,19 @@ async fn dispatch_ready_ticket_planning_return(
socket_path,
} => {
let message =
build_ready_ticket_refinement_notify(&ticket.meta.id, &request.user_instruction);
build_ready_ticket_refinement_notify(ticket_key, &request.user_instruction);
match send_notify_only(&socket_path, message, true).await {
Ok(()) => ReadyTicketPlanningReturnOutcome {
notice: format!(
"Ticket {} returned to planning for refinement; notified live Intake Worker {}.",
ticket.meta.id, worker_name
ticket_key, worker_name
),
followup: ReadyTicketPlanningReturnAfterMutation::None,
},
Err(error) => ReadyTicketPlanningReturnOutcome {
notice: bounded_panel_diagnostic(format!(
"Ticket {} returned to planning and instruction was recorded, but notifying Intake Worker {} failed: {}",
ticket.meta.id, worker_name, error
ticket_key, worker_name, error
)),
followup: ReadyTicketPlanningReturnAfterMutation::None,
},
@@ -4020,7 +4016,7 @@ async fn dispatch_ready_ticket_planning_return(
ReadyTicketPlanningReturnOutcome {
notice: format!(
"Ticket {} returned to planning for refinement; opening/restoring claimed Intake Worker {}…",
ticket.meta.id, worker_name
ticket_key, worker_name
),
followup: ReadyTicketPlanningReturnAfterMutation::OpenClaim(request),
}
@@ -4029,7 +4025,7 @@ async fn dispatch_ready_ticket_planning_return(
ReadyTicketPlanningReturnOutcome {
notice: bounded_panel_diagnostic(format!(
"Ticket {} returned to planning and instruction was recorded, but Intake launch was not attempted because existing Intake claim {} is stale; inspect or clear the local claim before launching another Intake Worker.",
ticket.meta.id, worker_name
ticket_key, worker_name
)),
followup: ReadyTicketPlanningReturnAfterMutation::None,
}
@@ -5204,6 +5200,7 @@ fn row_status_label(entry: &WorkerListEntry) -> (&'static str, Style) {
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Some(WorkerStatus::Stopped) => ("live stopped", Style::default().fg(Color::DarkGray)),
None => ("live", Style::default().fg(Color::DarkGray)),
};
}
+111 -8
View File
@@ -390,6 +390,7 @@ fn planning_return_request(
ReadyTicketPlanningReturnRequest {
workspace_root: temp.path().to_path_buf(),
ticket_id,
ticket_key: "T-482".to_string(),
user_instruction: instruction.to_string(),
followup: ReadyTicketPlanningReturnFollowup::BlockedByStaleClaim {
worker_name: "stale-intake".to_string(),
@@ -494,6 +495,7 @@ fn ready_ticket_intake_enter_prepares_planning_return_not_queue_or_generic_launc
};
assert_eq!(request.ticket_id, "20260608-000123-ready");
assert_eq!(request.ticket_key, "T-1");
assert_eq!(request.user_instruction, "clarify expected behavior");
assert!(matches!(
request.followup,
@@ -515,6 +517,7 @@ async fn planning_return_with_launch_followup_changes_state_before_launch_follow
let request = ReadyTicketPlanningReturnRequest {
workspace_root: temp.path().to_path_buf(),
ticket_id: ticket_id.clone(),
ticket_key: "T-482".to_string(),
user_instruction: "launch intake after state change".to_string(),
followup: ReadyTicketPlanningReturnFollowup::LaunchIntake(IntakeLaunchRequest {
context: TicketRoleLaunchContext::new(temp.path().to_path_buf(), TicketRole::Intake),
@@ -2969,7 +2972,7 @@ fn dashboard_empty_enter_on_non_openable_row_reports_open_diagnostic() {
}
#[test]
fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() {
fn idle_orchestrator_gets_sanitized_attention_for_new_queued_work() {
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
app.panel.rows = vec![panel_test_ticket_row(
"00001QUEUE",
@@ -2989,11 +2992,87 @@ fn idle_orchestrator_gets_bounded_attention_for_new_queued_work() {
request
.notice
.message
.starts_with("Workspace Dashboard observed")
.starts_with("Queued Tickets require attention:")
);
assert!(request.notice.message.contains("00001QUEUE"));
assert!(request.notice.message.contains("new_queued"));
assert!(request.notice.message.contains("queued -> inprogress"));
assert!(request.notice.message.contains("- T-1 — Queued work"));
assert!(
request
.notice
.message
.contains("Reread the current Ticket state before acting")
);
assert!(
!request
.notice
.message
.contains(&app.panel.header.workspace_label)
);
for hidden in [
"00001QUEUE",
"Workspace:",
"workspace_id",
"new_queued",
"bounded",
"queued -> inprogress",
] {
assert!(!request.notice.message.contains(hidden), "leaked {hidden}");
}
}
#[test]
fn queued_attention_missing_resource_key_fails_closed_with_panel_notice() {
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
let mut row = panel_test_ticket_row(
"00001QUEUE",
"Queued work",
ActionPriority::Background,
NextUserAction::Wait,
"queued",
);
row.ticket.as_mut().unwrap().resource_key = None;
app.panel.rows = vec![row];
app.refresh_orchestrator_work_set();
assert!(app.prepare_orchestrator_queue_attention_notice().is_none());
assert_eq!(
app.notice.as_deref(),
Some(
"Orchestrator queued-work attention not delivered: queued Ticket is missing its required resource key"
)
);
}
#[test]
fn queued_attention_truncates_only_when_tickets_are_omitted() {
let mut app = ticket_enabled_app(vec![live_info("test-orchestrator", WorkerStatus::Idle)]);
app.panel.rows = (1..=worker::OrchestratorQueueAttentionContext::MAX_TICKETS + 1)
.map(|index| {
let mut row = panel_test_ticket_row(
&format!("opaque-{index}"),
&format!("Queued work {index}"),
ActionPriority::Background,
NextUserAction::Wait,
"queued",
);
row.ticket.as_mut().unwrap().resource_key = Some(format!("T-{index}"));
row
})
.collect();
app.refresh_orchestrator_work_set();
let request = app
.prepare_orchestrator_queue_attention_notice()
.expect("bounded queued-work attention");
assert!(request.notice.message.contains("- T-20 — Queued work 20"));
assert!(!request.notice.message.contains("T-21"));
assert!(
request
.notice
.message
.contains("were omitted from this notice: 1")
);
assert!(!request.notice.message.contains("opaque-"));
}
#[test]
@@ -3083,7 +3162,9 @@ fn planned_queued_prompts_when_active_work_clears() {
.prepare_orchestrator_queue_attention_notice()
.expect("planned queued work should prompt after active work clears");
assert!(request.notice.message.contains("planned_queued"));
assert!(request.notice.message.contains("- T-1 — Queued work"));
assert!(!request.notice.message.contains("planned_queued"));
assert!(!request.notice.message.contains("00001QUEUE"));
assert!(
!request
.notice
@@ -3138,8 +3219,9 @@ fn rediscovered_queued_work_is_actionable_when_session_work_set_is_empty() {
.prepare_orchestrator_queue_attention_notice()
.expect("queued ticket state should be rediscovered safely");
assert!(request.notice.message.contains("new_queued"));
assert!(request.notice.message.contains("00001QUEUE"));
assert!(request.notice.message.contains("- T-1 — Queued work"));
assert!(!request.notice.message.contains("new_queued"));
assert!(!request.notice.message.contains("00001QUEUE"));
}
#[test]
@@ -3425,6 +3507,27 @@ fn ticket_action_error_records_f2_diagnostic_details() {
assert!(!app.panel_diagnostic_open);
}
#[test]
fn ready_ticket_refinement_projection_uses_only_canonical_resource_key() {
const INTERNAL_ID: &str = "00001KZVNXFNK";
let thread = build_ready_ticket_refinement_thread_body("T-482", "Clarify rollback.");
let launch = build_ready_ticket_refinement_launch_instruction("T-482", "Clarify rollback.");
let notify = build_ready_ticket_refinement_notify("T-482", "Clarify rollback.");
for projection in [&thread, &launch, &notify] {
assert!(projection.contains("T-482"));
assert!(!projection.contains(INTERNAL_ID));
}
}
#[test]
fn ticket_handoff_fails_closed_without_canonical_resource_key() {
assert_eq!(required_ticket_handoff_key(Some("T-482")), Ok("T-482"));
for invalid in [None, Some(""), Some("00001KZVNXFNK"), Some("T-key")] {
assert!(required_ticket_handoff_key(invalid).is_err());
}
}
fn plain_line(line: &Line<'_>) -> String {
line.spans
.iter()
+1
View File
@@ -1530,6 +1530,7 @@ fn worker_status_label(entry: &WorkerListEntry) -> &'static str {
Some(WorkerStatus::Idle) => "live idle",
Some(WorkerStatus::Running) => "live running",
Some(WorkerStatus::Paused) => "live paused",
Some(WorkerStatus::Stopped) => "live stopped",
None => "live",
};
}
+157 -58
View File
@@ -251,14 +251,8 @@ impl DelegatingWorkdirSession {
self.ensure_path(path, WorkdirDelegationPermission::Write)
}
fn ensure_command(&self, starting: bool) -> Result<(), WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Command, "command execution")?;
if starting && self.has_active_write_lease() {
return Err(WorkdirError::Denied(
"command execution is denied while a child holds a write delegation".into(),
));
}
Ok(())
fn ensure_command(&self) -> Result<(), WorkdirError> {
self.ensure_capability(WorkdirSessionCapability::Command, "command execution")
}
fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
@@ -281,20 +275,6 @@ impl DelegatingWorkdirSession {
}
}
fn has_active_write_lease(&self) -> bool {
let mut leases = self
.child_write_leases
.lock()
.expect("workdir delegation lease mutex poisoned");
leases.retain(|_, lease| lease.validity.upgrade().is_some_and(|v| v.is_active()));
leases.values().any(|lease| {
lease
.rules
.iter()
.any(|rule| rule.permission == WorkdirDelegationPermission::Write)
})
}
fn validate_delegation_rules(
&self,
rules: &[WorkdirDelegationRule],
@@ -503,12 +483,12 @@ impl WorkdirSession for DelegatingWorkdirSession {
}
async fn start_command(&self, request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
self.ensure_command(true)?;
self.ensure_command()?;
self.source.start_command(request).await
}
async fn command_status(&self, handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
self.ensure_command(false)?;
self.ensure_command()?;
self.source.command_status(handle).await
}
@@ -516,12 +496,12 @@ impl WorkdirSession for DelegatingWorkdirSession {
&self,
request: CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError> {
self.ensure_command(false)?;
self.ensure_command()?;
self.source.command_output(request).await
}
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
self.ensure_command(false)?;
self.ensure_command()?;
self.source.cancel_command(handle).await
}
@@ -753,6 +733,31 @@ mod tests {
}
}
async fn run_command(
session: &WorkdirSessionHandle,
command: impl Into<String>,
tool_call_id: impl Into<String>,
) -> CommandOutput {
let handle = session
.start_command(CommandRequest {
command: command.into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some(tool_call_id.into()),
})
.await
.unwrap();
session
.command_output(CommandOutputRequest {
handle,
cursor: 0,
limit: 1024,
wait: true,
})
.await
.unwrap()
}
#[tokio::test]
async fn delegation_capable_session_forwards_command_telemetry() {
let root = TempDir::new().unwrap();
@@ -846,6 +851,18 @@ mod tests {
);
assert!(child.scoped_session.subscribe_command_events().is_none());
assert!(child.scoped_session.command_snapshot().is_empty());
assert!(matches!(
child
.scoped_session
.start_command(CommandRequest {
command: "printf denied".into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some("read-only-command".into()),
})
.await,
Err(WorkdirError::Denied(_))
));
}
#[cfg(unix)]
@@ -924,7 +941,7 @@ mod tests {
}
#[tokio::test]
async fn write_lease_blocks_parent_region_until_release() {
async fn write_lease_keeps_typed_parent_writes_exclusive_without_blocking_commands() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("leased")).unwrap();
fs::create_dir_all(root.path().join("other")).unwrap();
@@ -938,37 +955,24 @@ mod tests {
.capabilities
.supports(WorkdirSessionCapability::Command)
);
let command = child
.scoped_session
.start_command(CommandRequest {
command: "printf child-command".into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some("delegated-child-command".into()),
})
.await
.unwrap();
let command_output = child
.scoped_session
.command_output(CommandOutputRequest {
handle: command,
cursor: 0,
limit: 1024,
wait: true,
})
.await
.unwrap();
assert_eq!(command_output.content, "child-command");
assert!(
parent
.start_command(CommandRequest {
command: "printf parent-command".into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some("blocked-parent-command".into()),
})
.await
.is_err()
let child_output = run_command(
&child.scoped_session,
"printf child-command",
"delegated-child-command",
)
.await;
assert_eq!(child_output.content, "child-command");
let parent_output = run_command(
&parent,
"printf parent-write > leased/from-command; printf parent-command",
"parent-command-during-child-write",
)
.await;
assert_eq!(parent_output.status, CommandStatus::Completed);
assert_eq!(parent_output.content, "parent-command");
assert_eq!(
fs::read_to_string(root.path().join("leased/from-command")).unwrap(),
"parent-write"
);
assert!(matches!(
@@ -982,6 +986,18 @@ mod tests {
.await
.unwrap();
child.release();
assert!(matches!(
child
.scoped_session
.start_command(CommandRequest {
command: "printf revoked".into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some("revoked-child-command".into()),
})
.await,
Err(WorkdirError::SessionClosed)
));
parent
.write(write("leased/parent", "parent"))
.await
@@ -1033,6 +1049,78 @@ mod tests {
));
}
#[tokio::test]
async fn nested_write_leases_do_not_block_command_capable_ancestors() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("docs/sub")).unwrap();
let root_session = session(root.path());
let child = root_session
.delegate(request("docs", WorkdirDelegationPermission::Write))
.await
.unwrap();
let nested = child
.scoped_session
.delegate(request("docs/sub", WorkdirDelegationPermission::Write))
.await
.unwrap();
for (session, label) in [
(&root_session, "root"),
(&child.scoped_session, "child"),
(&nested.scoped_session, "nested"),
] {
let output = run_command(
session,
format!("printf {label}"),
format!("{label}-command-during-nested-write"),
)
.await;
assert_eq!(output.status, CommandStatus::Completed);
assert_eq!(output.content, label);
}
assert!(matches!(
root_session.write(write("docs/root", "blocked")).await,
Err(WorkdirError::Denied(_))
));
assert!(matches!(
child
.scoped_session
.write(write("sub/child", "blocked"))
.await,
Err(WorkdirError::Denied(_))
));
nested
.scoped_session
.write(write("nested", "allowed"))
.await
.unwrap();
nested.release();
child.release();
}
#[tokio::test]
async fn reapplied_write_delegation_chain_forwards_command_lifecycle() {
let root = TempDir::new().unwrap();
fs::create_dir_all(root.path().join("delegated")).unwrap();
let applied = apply_delegation_chain(
session(root.path()),
[request("delegated", WorkdirDelegationPermission::Write)],
)
.await
.unwrap();
let output = run_command(
&applied.scoped_session,
"printf reapplied",
"reapplied-command",
)
.await;
assert_eq!(output.status, CommandStatus::Completed);
assert_eq!(output.content, "reapplied");
}
#[tokio::test]
async fn applied_chain_cannot_replace_outer_provider_attenuation() {
let root = TempDir::new().unwrap();
@@ -1077,6 +1165,17 @@ mod tests {
.unwrap();
parent.close().await.unwrap();
assert!(matches!(
parent
.start_command(CommandRequest {
command: "printf closed".into(),
timeout_secs: 5,
output_limit: 1024,
tool_call_id: Some("closed-parent-command".into()),
})
.await,
Err(WorkdirError::SessionClosed)
));
assert!(matches!(
child.scoped_session.read(read("a")).await,
Err(WorkdirError::SessionClosed)
+3 -1
View File
@@ -28,7 +28,9 @@ pub use fs_operation::{
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
};
pub use local::{LocalWorkdirSession, SymlinkInfo, direct_symlink, first_symlink};
pub use local::{
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
};
pub use operation::*;
/// Persistent, opaque identity of one materialized Workdir.
+69 -2
View File
@@ -8,7 +8,8 @@
//! `LocalWorkdirSession` is cheap to clone (`Arc` inside). Tool-specific session
//! state, such as read-before-edit tracking, remains owned by the tool layer.
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::fmt::Debug;
#[cfg(test)]
use std::io::Write as _;
use std::io::{Read as _, Seek as _, SeekFrom};
@@ -228,6 +229,8 @@ struct LocalWorkdirSessionInner {
next_command_id: AtomicU64,
commands: Mutex<HashMap<String, LocalCommand>>,
command_telemetry: CommandTelemetry,
command_environment: BTreeMap<String, String>,
resources: StdMutex<Vec<Arc<dyn WorkdirSessionResource>>>,
}
impl Drop for LocalWorkdirSessionInner {
@@ -242,6 +245,9 @@ impl Drop for LocalWorkdirSessionInner {
}
}
pub trait WorkdirSessionResource: Debug + Send + Sync {}
impl<T> WorkdirSessionResource for T where T: Debug + Send + Sync {}
/// Scope-aware filesystem handle. Clone-cheap (`Arc` inside).
///
/// The wrapped [`SharedScope`] is shared with every clone of this
@@ -318,6 +324,26 @@ impl LocalWorkdirSession {
cwd: PathBuf,
scope: SharedScope,
capabilities: WorkdirSessionCapabilities,
) -> Self {
Self::materialized_bound_with_environment(
workdir,
root,
cwd,
scope,
capabilities,
BTreeMap::new(),
Vec::new(),
)
}
pub fn materialized_bound_with_environment(
workdir: Workdir,
root: PathBuf,
cwd: PathBuf,
scope: SharedScope,
capabilities: WorkdirSessionCapabilities,
command_environment: BTreeMap<String, String>,
resources: Vec<Arc<dyn WorkdirSessionResource>>,
) -> Self {
Self {
inner: Arc::new(LocalWorkdirSessionInner {
@@ -331,6 +357,8 @@ impl LocalWorkdirSession {
next_command_id: AtomicU64::new(1),
commands: Mutex::new(HashMap::new()),
command_telemetry: CommandTelemetry::new(),
command_environment,
resources: StdMutex::new(resources),
}),
}
}
@@ -669,9 +697,18 @@ impl WorkdirSession for LocalWorkdirSession {
let (completion_tx, completion) = watch::channel(false);
let command_id = handle.0.clone();
let telemetry = self.inner.command_telemetry.clone();
let command_environment = self.inner.command_environment.clone();
let (cancel, cancel_rx) = watch::channel(false);
let task = tokio::spawn(async move {
let output = run_command(cwd, request, command_id, telemetry, cancel_rx).await;
let output = run_command(
cwd,
request,
command_id,
telemetry,
command_environment,
cancel_rx,
)
.await;
let _ = completion_tx.send(true);
output
});
@@ -840,6 +877,9 @@ impl WorkdirSession for LocalWorkdirSession {
LocalCommand::Completed(_) => {}
}
}
if let Ok(mut resources) = self.inner.resources.lock() {
resources.clear();
}
Ok(())
}
}
@@ -909,6 +949,7 @@ async fn run_command(
request: CommandRequest,
command_id: String,
telemetry: CommandTelemetry,
command_environment: BTreeMap<String, String>,
mut cancel: watch::Receiver<bool>,
) -> Result<CommandOutput, WorkdirError> {
let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?;
@@ -925,6 +966,7 @@ async fn run_command(
.arg("-c")
.arg(&request.command)
.current_dir(&cwd)
.envs(command_environment)
.stdin(Stdio::null())
.stdout(Stdio::from(stdout_file))
.stderr(Stdio::from(stderr_file))
@@ -2319,6 +2361,31 @@ mod tests {
assert_eq!(terminal, Some((handle.0, CommandStatus::TimedOut, None)));
}
#[tokio::test]
async fn closing_session_releases_runtime_resources() {
#[derive(Debug)]
struct Resource(Arc<AtomicBool>);
impl Drop for Resource {
fn drop(&mut self) {
self.0.store(true, Ordering::Release);
}
}
let dir = TempDir::new().unwrap();
let released = Arc::new(AtomicBool::new(false));
let session = LocalWorkdirSession::materialized_bound_with_environment(
Workdir::new("resource-session"),
dir.path().to_path_buf(),
dir.path().to_path_buf(),
SharedScope::new(Scope::writable(dir.path()).unwrap()),
WorkdirSessionCapabilities::ALL,
BTreeMap::from([("SSH_AUTH_SOCK".to_string(), "test-socket".to_string())]),
vec![Arc::new(Resource(released.clone()))],
);
WorkdirSession::close(&session).await.unwrap();
assert!(released.load(Ordering::Acquire));
}
#[tokio::test]
async fn provider_cancels_active_command() {
let dir = TempDir::new().unwrap();
+23
View File
@@ -30,6 +30,8 @@ impl RuntimeWorkerRef {
#[serde(rename_all = "snake_case")]
pub enum MaterializerKind {
#[default]
RuntimeGitCache,
/// Legacy persisted value from the pre-cache local `git worktree` materializer.
LocalGitWorktree,
}
@@ -109,6 +111,8 @@ pub struct WorkingDirectoryProvenance {
pub creation_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_tree: Option<String>,
pub materializer_kind: MaterializerKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
@@ -122,6 +126,10 @@ pub struct WorkingDirectoryCurrentObservation {
pub current_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_at_epoch_seconds: Option<u64>,
pub status: WorkingDirectoryStatusKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanliness: Option<String>,
@@ -141,9 +149,15 @@ pub struct WorkingDirectorySummary {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creation_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_tree: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observed_at_epoch_seconds: Option<u64>,
pub materializer_kind: MaterializerKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleanup_target: Option<WorkingDirectoryCleanupTarget>,
@@ -166,6 +180,7 @@ impl WorkingDirectorySummary {
WorkingDirectoryProvenance {
creation_selector: self.creation_selector.clone(),
creation_ref: self.creation_ref.clone(),
creation_tree: self.creation_tree.clone(),
materializer_kind: self.materializer_kind.clone(),
cleanup_target: self.cleanup_target.clone(),
}
@@ -175,6 +190,8 @@ impl WorkingDirectorySummary {
WorkingDirectoryCurrentObservation {
current_selector: self.current_selector.clone(),
current_ref: self.current_ref.clone(),
current_tree: self.current_tree.clone(),
observed_at_epoch_seconds: self.observed_at_epoch_seconds,
status: self.status.clone(),
cleanliness: self.cleanliness.clone(),
primary_worker_id: self.primary_worker_id.clone(),
@@ -247,8 +264,11 @@ mod tests {
repository_id: "repo".to_string(),
creation_selector: Some("develop".to_string()),
creation_ref: Some("abc123".to_string()),
creation_tree: Some("tree123".to_string()),
current_selector: Some("work/ticket".to_string()),
current_ref: Some("def456".to_string()),
current_tree: Some("tree456".to_string()),
observed_at_epoch_seconds: Some(1_777_777_777),
materializer_kind: MaterializerKind::LocalGitWorktree,
cleanup_target: Some(WorkingDirectoryCleanupTarget {
kind: "git_worktree".to_string(),
@@ -269,8 +289,11 @@ mod tests {
repository_id: "repo".to_string(),
creation_selector: None,
creation_ref: None,
creation_tree: None,
current_selector: None,
current_ref: Some("987fed".to_string()),
current_tree: None,
observed_at_epoch_seconds: None,
materializer_kind: MaterializerKind::LocalGitWorktree,
cleanup_target: None,
status: WorkingDirectoryStatusKind::Active,
+1
View File
@@ -43,6 +43,7 @@ tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
toml.workspace = true
url.workspace = true
uuid = { workspace = true, features = ["v7"] }
zeroize.workspace = true
tower = { workspace = true, features = ["util"], optional = true }
worker.workspace = true
workspace-api = { path = "../workspace-api" }
+71
View File
@@ -97,6 +97,74 @@ pub use workdir::workspace::{
WorkingDirectorySummary,
};
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SensitiveString(String);
impl SensitiveString {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn expose(&self) -> &str {
&self.0
}
}
impl Drop for SensitiveString {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.0);
}
}
impl Default for SensitiveString {
fn default() -> Self {
Self(String::new())
}
}
impl std::fmt::Debug for SensitiveString {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("[REDACTED]")
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySshMaterializationAccess {
pub credential_id: String,
pub credential_revision: u64,
pub host_trust_id: String,
pub host_trust_revision: u64,
pub access: workspace_api::RepositoryAccessMode,
pub expires_at_epoch_seconds: u64,
pub repository_id: String,
pub repository_source_fingerprint: String,
pub repository_uri: String,
pub secret_resource: crate::resource::BackendResourceHandle,
#[serde(skip, default)]
pub private_key: SensitiveString,
#[serde(skip, default)]
pub known_hosts_entry: SensitiveString,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositoryMaterializationContext {
pub workspace_id: String,
pub runtime_id: String,
pub operation_id: String,
pub config_revision: u64,
pub config_projection_digest: String,
#[serde(default)]
pub cache_generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<RepositorySshMaterializationAccess>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryRepositoryAccessRequest {
pub working_directory_id: String,
pub materialization: RepositoryMaterializationContext,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkingDirectoryRequest {
pub repository: WorkingDirectoryRepository,
@@ -106,6 +174,9 @@ pub struct WorkingDirectoryRequest {
/// Backend can create canonical registry rows before materialization.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backend_workdir_id: Option<String>,
/// Backend-authored, operation-scoped repository access and cache identity.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub materialization: Option<RepositoryMaterializationContext>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+21 -1
View File
@@ -1,4 +1,6 @@
use crate::catalog::{WorkingDirectoryRequest, WorkingDirectoryStatus};
use crate::catalog::{
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
};
use crate::config_bundle::ConfigBundle;
use crate::error::RuntimeError;
use crate::identity::WorkerRef;
@@ -319,6 +321,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static {
))
}
fn authorize_working_directory_repository_access(
&self,
_request: &WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), WorkingDirectoryDiagnostic> {
Err(WorkingDirectoryDiagnostic::rejected(
"working_directory_repository_access_unsupported",
"Worker execution backend does not support Repository access authorization",
))
}
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
Vec::new()
}
@@ -454,6 +466,14 @@ impl WorkerExecutionBackendRef {
self.backend.create_working_directory(request)
}
pub(crate) fn authorize_working_directory_repository_access(
&self,
request: &WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), WorkingDirectoryDiagnostic> {
self.backend
.authorize_working_directory_repository_access(request)
}
pub(crate) fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
self.backend.list_working_directories()
}
+53 -2
View File
@@ -12,7 +12,8 @@ use crate::auth::{
};
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, WorkerDetail, WorkerLifecycleAck, WorkerSummary,
WorkingDirectoryRequest, WorkingDirectoryStatus, WorkspaceApiRef,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkspaceApiRef,
};
use crate::config_bundle::{ConfigBundle, ConfigBundleAvailability, ConfigBundleSummary};
use crate::error::RuntimeError;
@@ -203,6 +204,10 @@ fn runtime_http_router_with_optional_auth(
"/v1/working-directories",
get(list_working_directories).post(create_working_directory),
)
.route(
"/v1/working-directories/repository-access",
post(authorize_working_directory_repository_access),
)
.route(
"/v1/working-directories/{working_directory_id}/sessions",
post(open_workdir_session),
@@ -335,6 +340,11 @@ pub struct RuntimeHttpWorkingDirectoriesResponse {
pub working_directories: Vec<WorkingDirectoryStatus>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpRepositoryAccessResponse {
pub authorized: bool,
}
/// Working directory response used by create/detail/delete endpoints.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpWorkingDirectoryResponse {
@@ -513,6 +523,29 @@ async fn list_workers(
Ok(Json(RuntimeHttpWorkersResponse { workers }))
}
async fn authorize_working_directory_repository_access(
State(state): State<RuntimeHttpState>,
Extension(auth): Extension<RuntimeAuthContext>,
body: Result<Json<WorkingDirectoryRepositoryAccessRequest>, JsonRejection>,
) -> RestResult<RuntimeHttpRepositoryAccessResponse> {
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
if request.materialization.workspace_id != auth.workspace_id {
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"working_directory_materialization_workspace_mismatch",
"Repository access authority does not match the authenticated Workspace",
));
}
state
.runtime
.authorize_working_directory_repository_access_from_resource(request)
.await
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpRepositoryAccessResponse {
authorized: true,
}))
}
async fn list_working_directories(
State(state): State<RuntimeHttpState>,
) -> RestResult<RuntimeHttpWorkingDirectoriesResponse> {
@@ -527,12 +560,23 @@ async fn list_working_directories(
async fn create_working_directory(
State(state): State<RuntimeHttpState>,
Extension(auth): Extension<RuntimeAuthContext>,
body: Result<Json<WorkingDirectoryRequest>, JsonRejection>,
) -> RestResult<RuntimeHttpWorkingDirectoryResponse> {
let Json(request) = body.map_err(RuntimeHttpRestError::json_rejection)?;
if let Some(materialization) = request.materialization.as_ref()
&& materialization.workspace_id != auth.workspace_id
{
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"working_directory_materialization_workspace_mismatch",
"Repository materialization authority does not match the authenticated Workspace",
));
}
let working_directory = state
.runtime
.create_working_directory(request)
.create_working_directory_from_resource(request)
.await
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkingDirectoryResponse {
working_directory,
@@ -1559,6 +1603,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
if path == "/v1/workers" && *method == Method::POST {
return Some("workers:create");
}
if path == "/v1/working-directories/repository-access" && *method == Method::POST {
return Some("workdirs:operate");
}
if path.starts_with("/v1/workdir-sessions")
|| (path.starts_with("/v1/working-directories/") && path.ends_with("/sessions"))
{
@@ -2220,6 +2267,10 @@ mod tests {
#[test]
fn workdir_routes_require_dedicated_operation_permission() {
assert_eq!(
required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",),
Some("workdirs:operate")
);
assert_eq!(
required_runtime_permission(&Method::POST, "/v1/working-directories/wd-1/sessions"),
Some("workdirs:operate")
+34 -10
View File
@@ -23,10 +23,21 @@ use worker_runtime::http_server::{
RuntimeHttpServerConfig, RuntimeHttpServerError, RuntimeHttpStoreSelection,
};
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use worker_runtime::working_directory::LocalGitWorktreeMaterializer;
use worker_runtime::working_directory::RuntimeGitCacheMaterializer;
use worker_runtime::{Runtime, RuntimeOptions};
fn main() -> ExitCode {
let mut arguments = std::env::args().skip(1).collect::<Vec<_>>();
if arguments.first().map(String::as_str) == Some("__repository-ssh") {
arguments.remove(0);
return match worker_runtime::working_directory::run_repository_ssh_client(&arguments) {
Ok(status) => ExitCode::from(u8::try_from(status).unwrap_or(1)),
Err(error) => {
eprintln!("{error}");
ExitCode::from(1)
}
};
}
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
@@ -169,6 +180,9 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
factory = factory.with_remote_worker_mutation_identity(identity);
}
}
let mut backend_resource_client: Option<
Arc<dyn worker_runtime::resource::BackendResourceClient>,
> = None;
if let Some(endpoint) = config.backend_resource_endpoint.clone() {
let identity = runtime_auth.identity.as_ref().ok_or_else(|| {
ProcessError::Auth(
@@ -181,26 +195,28 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
.to_owned(),
));
};
factory = factory.with_resource_client(Arc::new(
let client = Arc::new(
worker_runtime::resource::HttpBackendResourceClient::new(
endpoint,
config.backend_resource_token.clone(),
)
.with_runtime_request_source(identity, trusted_server.server_id.clone()),
));
);
factory = factory.with_resource_client(client.clone());
backend_resource_client = Some(client);
}
let backend = Arc::new(
WorkerRuntimeExecutionBackend::new(factory)
.map_err(ProcessError::WorkerAdapter)?
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
fs_paths.workdir_target.clone(),
)),
);
match &config.http.store {
let runtime = match &config.http.store {
RuntimeHttpStoreSelection::Memory => {
Runtime::with_execution_backend(runtime_options_from_http(&config.http), backend)
.map_err(ProcessError::Runtime)
.map_err(ProcessError::Runtime)?
}
RuntimeHttpStoreSelection::Fs { root } => {
let mut options = FsRuntimeStoreOptions::new(root.clone()).with_runtime_id(
@@ -213,12 +229,20 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
);
options.display_name = config.http.display_name.clone();
Runtime::with_fs_store_and_execution_backend(options, backend)
.map_err(ProcessError::Runtime)
.map_err(ProcessError::Runtime)?
}
_ => Err(ProcessError::usage(
"unsupported Runtime catalog store selection".to_string(),
)),
_ => {
return Err(ProcessError::usage(
"unsupported Runtime catalog store selection".to_string(),
));
}
};
if let Some(client) = backend_resource_client {
runtime
.install_backend_resource_client(client)
.map_err(ProcessError::Runtime)?;
}
Ok(runtime)
}
fn runtime_options_from_http(config: &RuntimeHttpServerConfig) -> RuntimeOptions {
+56 -5
View File
@@ -11,18 +11,46 @@ use std::sync::Mutex;
pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str =
"application/vnd.yoi.profile-source-archive+tar";
pub const REPOSITORY_SSH_ACCESS_CONTENT_TYPE: &str =
"application/vnd.yoi.repository-ssh-access+json";
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024;
#[derive(Clone, Serialize, Deserialize)]
pub struct RepositorySshAccessSecret {
pub private_key: String,
pub known_hosts_entry: String,
}
impl Drop for RepositorySshAccessSecret {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.private_key);
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry);
}
}
impl std::fmt::Debug for RepositorySshAccessSecret {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RepositorySshAccessSecret")
.field("private_key", &"[REDACTED]")
.field("known_hosts_entry", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackendResourceKind {
ProfileSourceArchive,
RepositorySshAccess,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BackendResourceOperation {
FetchArchive,
FetchOnce,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -66,7 +94,7 @@ pub struct BackendResourceFetchRequest {
pub audit_correlation_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackendResourceFetchResponse {
pub kind: BackendResourceKind,
pub resource_id: String,
@@ -76,6 +104,29 @@ pub struct BackendResourceFetchResponse {
pub audit_correlation_id: String,
}
impl std::fmt::Debug for BackendResourceFetchResponse {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("BackendResourceFetchResponse")
.field("kind", &self.kind)
.field("resource_id", &self.resource_id)
.field("digest", &self.digest)
.field("content_type", &self.content_type)
.field(
"bytes",
&format_args!("[REDACTED; {} bytes]", self.bytes.len()),
)
.field("audit_correlation_id", &self.audit_correlation_id)
.finish()
}
}
impl Drop for BackendResourceFetchResponse {
fn drop(&mut self) {
self.bytes.fill(0);
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
#[serde(tag = "code", rename_all = "snake_case")]
pub enum BackendResourceError {
@@ -248,7 +299,7 @@ pub fn build_profile_source_archive_fetch_request(
pub fn profile_source_archive_from_response(
handle: &BackendResourceHandle,
response: BackendResourceFetchResponse,
mut response: BackendResourceFetchResponse,
) -> Result<ProfileSourceArchive, BackendResourceError> {
if handle.kind != BackendResourceKind::ProfileSourceArchive
|| response.kind != BackendResourceKind::ProfileSourceArchive
@@ -263,7 +314,7 @@ pub fn profile_source_archive_from_response(
if response.content_type != handle.content_type {
return Err(BackendResourceError::ContentTypeMismatch {
expected: handle.content_type.clone(),
actual: response.content_type,
actual: response.content_type.clone(),
});
}
let actual_bytes = response.bytes.len() as u64;
@@ -278,7 +329,7 @@ pub fn profile_source_archive_from_response(
return Err(BackendResourceError::DigestMismatch {
expected: handle.digest.clone(),
actual: if response.digest != handle.digest {
response.digest
response.digest.clone()
} else {
actual_digest
},
@@ -296,7 +347,7 @@ pub fn profile_source_archive_from_response(
}
})?,
},
content: response.bytes,
content: std::mem::take(&mut response.bytes),
})
}
+449 -4
View File
@@ -1,6 +1,6 @@
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, WorkerDetail, WorkerLifecycleAck,
WorkerStatus, WorkerSummary, WorkingDirectoryRequest,
WorkerStatus, WorkerSummary, WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest,
WorkingDirectoryStatus as CatalogWorkingDirectoryStatus, WorkspaceApiRef,
};
use crate::config_bundle::{
@@ -26,6 +26,10 @@ use crate::management::{
};
#[cfg(feature = "ws-server")]
use crate::observation::{WorkerObservationCursor, WorkerObservationEvent};
use crate::resource::{
BackendResourceClient, BackendResourceError, BackendResourceFetchRequest, BackendResourceKind,
REPOSITORY_SSH_ACCESS_CONTENT_TYPE, RepositorySshAccessSecret,
};
#[cfg(feature = "fs-store")]
use crate::retention::{
FsWorkerRetentionProvider, WorkerRetentionExecutionRequest, WorkerRetentionExecutionResult,
@@ -172,6 +176,14 @@ impl Runtime {
Ok(runtime)
}
pub fn install_backend_resource_client(
&self,
client: Arc<dyn BackendResourceClient>,
) -> Result<(), RuntimeError> {
self.lock()?.backend_resource_client = Some(BackendResourceClientRef(client));
Ok(())
}
/// Create or restore a filesystem-backed Runtime.
///
/// The store is scoped by `options.root`; if the directory already exists,
@@ -366,6 +378,103 @@ impl Runtime {
.map_err(RuntimeError::from)
}
pub async fn create_working_directory_from_resource(
&self,
mut request: WorkingDirectoryRequest,
) -> Result<CatalogWorkingDirectoryStatus, RuntimeError> {
if let Some(ssh) = request
.materialization
.as_mut()
.and_then(|materialization| materialization.ssh.as_mut())
{
self.resolve_repository_access_resource(ssh).await?;
}
self.create_working_directory(request)
}
pub fn authorize_working_directory_repository_access(
&self,
request: WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), RuntimeError> {
let backend = {
let state = self.lock()?;
state.ensure_running()?;
state.execution_backend.clone().ok_or_else(|| {
RuntimeError::ExecutionBackendUnavailable {
message: "working directory Repository access requires an execution backend"
.to_string(),
}
})?
};
backend
.authorize_working_directory_repository_access(&request)
.map_err(RuntimeError::from)
}
async fn resolve_repository_access_resource(
&self,
ssh: &mut crate::catalog::RepositorySshMaterializationAccess,
) -> Result<(), RuntimeError> {
if !ssh.private_key.expose().is_empty() && !ssh.known_hosts_entry.expose().is_empty() {
return Ok(());
}
let (client, runtime_id) = {
let state = self.lock()?;
let client = state.backend_resource_client.clone().ok_or_else(|| {
RuntimeError::InvalidRequest(
"Backend Repository access resource client is unavailable".to_string(),
)
})?;
let runtime_id = state.runtime_identity.clone().ok_or_else(|| {
RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string())
})?;
(client, runtime_id)
};
let mut response = client
.0
.fetch_resource(BackendResourceFetchRequest {
handle: ssh.secret_resource.clone(),
runtime_id,
worker_id: None,
audit_correlation_id: ssh.secret_resource.audit_correlation_id.clone(),
})
.await
.map_err(repository_resource_error)?;
if response.kind != BackendResourceKind::RepositorySshAccess
|| response.content_type != REPOSITORY_SSH_ACCESS_CONTENT_TYPE
|| response.resource_id != ssh.secret_resource.resource_id
|| response.digest != ssh.secret_resource.digest
|| response.bytes.len() as u64 > ssh.secret_resource.max_bytes
{
return Err(RuntimeError::InvalidRequest(
"Backend Repository SSH access resource response was invalid".to_string(),
));
}
let secret = serde_json::from_slice::<RepositorySshAccessSecret>(&response.bytes);
response.bytes.fill(0);
let mut secret = secret.map_err(|_| {
RuntimeError::InvalidRequest(
"Backend Repository SSH access resource payload was invalid".to_string(),
)
})?;
ssh.private_key =
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key));
ssh.known_hosts_entry =
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.known_hosts_entry));
Ok(())
}
pub async fn authorize_working_directory_repository_access_from_resource(
&self,
mut request: WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), RuntimeError> {
let ssh = request.materialization.ssh.as_mut().ok_or_else(|| {
RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string())
})?;
self.resolve_repository_access_resource(ssh).await?;
self.authorize_working_directory_repository_access(request)
}
/// List Runtime-owned working directories through the attached execution backend.
pub fn list_working_directories(
&self,
@@ -566,12 +675,13 @@ impl Runtime {
let worker_id = request.worker_id;
let worker_ref = WorkerRef::new(worker_id);
let durable_request = durable_create_worker_request(&request);
let record = WorkerRecord {
worker_ref: worker_ref.clone(),
worker_id: worker_id.clone(),
status: WorkerStatus::Stopped,
workspace_id: scope.map(|scope| scope.workspace_id.clone()),
request: request.clone(),
request: durable_request,
run_generation: 1,
working_directory: None,
execution_handle: None,
@@ -1842,6 +1952,15 @@ struct SubscriptionSink {
lagged: Arc<AtomicBool>,
}
#[derive(Clone)]
struct BackendResourceClientRef(Arc<dyn BackendResourceClient>);
impl std::fmt::Debug for BackendResourceClientRef {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("BackendResourceClientRef(..)")
}
}
#[derive(Debug)]
struct RuntimeState {
display_name: Option<String>,
@@ -1853,6 +1972,7 @@ struct RuntimeState {
persistence: RuntimePersistence,
status: RuntimeStatus,
execution_backend: Option<WorkerExecutionBackendRef>,
backend_resource_client: Option<BackendResourceClientRef>,
#[cfg(feature = "fs-store")]
next_diagnostic_id: u64,
workers: BTreeMap<WorkerId, WorkerRecord>,
@@ -1880,6 +2000,7 @@ impl RuntimeState {
persistence: RuntimePersistence::Memory,
status: RuntimeStatus::Running,
execution_backend: None,
backend_resource_client: None,
#[cfg(feature = "fs-store")]
next_diagnostic_id: 1,
workers: BTreeMap::new(),
@@ -1908,6 +2029,7 @@ impl RuntimeState {
persistence: RuntimePersistence::Fs(store),
status: RuntimeStatus::Running,
execution_backend: None,
backend_resource_client: None,
#[cfg(feature = "fs-store")]
next_diagnostic_id: 1,
workers: BTreeMap::new(),
@@ -1959,6 +2081,7 @@ impl RuntimeState {
persistence: RuntimePersistence::Fs(store),
status: persisted.status,
execution_backend: None,
backend_resource_client: None,
next_diagnostic_id,
workers,
config_bundles: BTreeMap::new(),
@@ -2619,6 +2742,7 @@ impl RuntimeState {
protocol::WorkerStatus::Running => Some(WorkerStatus::Running),
protocol::WorkerStatus::Idle => Some(WorkerStatus::Idle),
protocol::WorkerStatus::Paused => Some(WorkerStatus::Paused),
protocol::WorkerStatus::Stopped => Some(WorkerStatus::Stopped),
},
protocol::Event::RunEnd { result } => match result {
protocol::RunResult::Finished | protocol::RunResult::RolledBack => {
@@ -2714,6 +2838,33 @@ fn worker_status_from_run_state(run_state: WorkerExecutionRunState) -> WorkerSta
}
}
fn repository_resource_error(error: BackendResourceError) -> RuntimeError {
let category = match error {
BackendResourceError::Expired => "expired",
BackendResourceError::Unauthorized { .. } => "unauthorized",
BackendResourceError::UnsupportedKind => "unsupported_kind",
BackendResourceError::MissingResource => "missing_resource",
BackendResourceError::Oversized { .. } => "oversized",
BackendResourceError::DigestMismatch { .. } => "digest_mismatch",
BackendResourceError::ContentTypeMismatch { .. } => "content_type_mismatch",
BackendResourceError::InvalidResponse { .. } => "invalid_response",
BackendResourceError::Transport { .. } => "transport",
};
RuntimeError::InvalidRequest(format!(
"Backend Repository SSH access resource fetch failed: {category}"
))
}
fn durable_create_worker_request(request: &CreateWorkerRequest) -> CreateWorkerRequest {
let mut durable = request.clone();
if let Some(working_directory) = durable.working_directory_request.as_mut()
&& let Some(materialization) = working_directory.materialization.as_mut()
{
materialization.ssh = None;
}
durable
}
fn requested_primary_workdir_id(request: &CreateWorkerRequest) -> Option<&str> {
request
.working_directory
@@ -2884,7 +3035,9 @@ fn subscription_worker_state(status: WorkerStatus) -> SubscriptionWorkerState {
mod tests {
use super::*;
use crate::catalog::{
ConfigBundleRef, ProfileSelector, WorkingDirectoryClaim, WorkspaceApiRef,
ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext,
RepositorySshMaterializationAccess, SensitiveString, WorkingDirectoryClaim,
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
};
use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
@@ -2894,6 +3047,8 @@ mod tests {
WorkerExecutionBackend, WorkerExecutionContext, WorkerExecutionHandle,
WorkerExecutionRestoreRequest, WorkerExecutionRunState,
};
use crate::working_directory::WorkingDirectoryDiagnostic;
use async_trait::async_trait;
use std::collections::BTreeMap;
#[cfg(feature = "fs-store")]
use std::sync::atomic::{AtomicU64, Ordering};
@@ -2950,7 +3105,7 @@ mod tests {
&mut activity,
&internal_worker_status_event(
internal_worker_ref("child-b", None),
protocol::WorkerStatus::Idle,
protocol::WorkerStatus::Stopped,
),
));
}
@@ -3115,6 +3270,243 @@ mod tests {
}
}
#[test]
fn durable_worker_request_omits_repository_credentials() {
let mut request = task_request("worker-secret-redaction");
request.working_directory_request = Some(WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: "repository-1".to_string(),
provider: "git".to_string(),
source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/repo.git".to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
materializer: MaterializerKind::RuntimeGitCache,
backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(),
credential_revision: 1,
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: repository_resource_handle(),
private_key: SensitiveString::new("private-key-bytes"),
known_hosts_entry: SensitiveString::new("known-hosts-entry"),
}),
}),
});
let durable = durable_create_worker_request(&request);
assert!(
request
.working_directory_request
.as_ref()
.and_then(|working_directory| working_directory.materialization.as_ref())
.and_then(|materialization| materialization.ssh.as_ref())
.is_some()
);
assert!(
durable
.working_directory_request
.as_ref()
.and_then(|working_directory| working_directory.materialization.as_ref())
.and_then(|materialization| materialization.ssh.as_ref())
.is_none()
);
let serialized = serde_json::to_string(&durable).unwrap();
assert!(!serialized.contains("private-key-bytes"));
assert!(!serialized.contains("known-hosts-entry"));
}
fn repository_resource_handle() -> crate::resource::BackendResourceHandle {
crate::resource::BackendResourceHandle {
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
workspace_id: "workspace-1".to_string(),
scope_id: Some("repository-ssh-access".to_string()),
runtime_id: Some("runtime-1".to_string()),
worker_id: None,
resource_id: "repository-access-1".to_string(),
digest: "opaque:repository-access-1".to_string(),
operation: crate::resource::BackendResourceOperation::FetchOnce,
expires_at_unix_seconds: i64::MAX,
nonce: "repository-access-1".to_string(),
revision: "1".to_string(),
generation: None,
max_bytes: crate::resource::DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES,
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly,
audit_correlation_id: "repository-access-1".to_string(),
profile_source_graph: None,
}
}
#[tokio::test]
async fn repository_access_resource_is_fetched_before_provider_authorization() {
let (runtime, backend) = runtime_and_backend();
backend
.repository_access_available
.store(true, Ordering::SeqCst);
runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle();
runtime
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
resource_id: handle.resource_id.clone(),
digest: handle.digest.clone(),
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
private_key: "private-key-bytes".to_string(),
known_hosts_entry: "known-hosts-entry".to_string(),
})
.unwrap(),
audit_correlation_id: handle.audit_correlation_id.clone(),
})),
}))
.unwrap();
let request = WorkingDirectoryRepositoryAccessRequest {
working_directory_id: "working-directory-1".to_string(),
materialization: RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(),
credential_revision: 1,
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: handle,
private_key: SensitiveString::default(),
known_hosts_entry: SensitiveString::default(),
}),
},
};
let replay = request.clone();
runtime
.authorize_working_directory_repository_access_from_resource(request)
.await
.unwrap();
assert!(
runtime
.authorize_working_directory_repository_access_from_resource(replay)
.await
.is_err()
);
let accesses = backend.repository_accesses.lock().unwrap();
assert_eq!(accesses.len(), 1);
let access = accesses[0].materialization.ssh.as_ref().unwrap();
assert_eq!(access.private_key.expose(), "private-key-bytes");
assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry");
}
#[tokio::test]
async fn working_directory_create_fetches_repository_access_before_provider_call() {
let (runtime, backend) = runtime_and_backend();
backend
.repository_access_available
.store(true, Ordering::SeqCst);
runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle();
runtime
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
resource_id: handle.resource_id.clone(),
digest: handle.digest.clone(),
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
private_key: "create-private-key-bytes".to_string(),
known_hosts_entry: "create-known-hosts-entry".to_string(),
})
.unwrap(),
audit_correlation_id: handle.audit_correlation_id.clone(),
})),
}))
.unwrap();
let request = WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
id: "repository-1".to_string(),
provider: "git".to_string(),
source: workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::Ssh,
uri: "ssh://git@example.test/repo.git".to_string(),
},
source_revision: 1,
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
materializer: MaterializerKind::RuntimeGitCache,
backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
runtime_id: "runtime-1".to_string(),
operation_id: "operation-create".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(),
credential_revision: 1,
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
expires_at_epoch_seconds: u64::MAX,
repository_id: "repository-1".to_string(),
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: handle,
private_key: SensitiveString::default(),
known_hosts_entry: SensitiveString::default(),
}),
}),
};
assert!(
runtime
.create_working_directory_from_resource(request)
.await
.is_err()
);
let requests = backend.working_directory_requests.lock().unwrap();
let access = requests[0]
.materialization
.as_ref()
.and_then(|materialization| materialization.ssh.as_ref())
.unwrap();
assert_eq!(access.private_key.expose(), "create-private-key-bytes");
assert_eq!(
access.known_hosts_entry.expose(),
"create-known-hosts-entry"
);
}
fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest {
let mut request = task_request(objective);
request.workspace_api = Some(WorkspaceApiRef {
@@ -3196,6 +3588,9 @@ mod tests {
config_bundles: Mutex<Vec<Option<ConfigBundle>>>,
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
dispatched_inputs: Mutex<Vec<WorkerInput>>,
repository_accesses: Mutex<Vec<WorkingDirectoryRepositoryAccessRequest>>,
repository_access_available: AtomicBool,
working_directory_requests: Mutex<Vec<WorkingDirectoryRequest>>,
preserve_commit_ack_submission_id: AtomicBool,
#[cfg(feature = "ws-server")]
snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>,
@@ -3236,6 +3631,38 @@ mod tests {
"test-execution-backend"
}
fn create_working_directory(
&self,
request: &WorkingDirectoryRequest,
) -> Result<CatalogWorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
self.working_directory_requests
.lock()
.unwrap()
.push(request.clone());
Err(WorkingDirectoryDiagnostic::rejected(
"working_directory_unsupported",
"Worker execution backend does not support working directory materialization",
))
}
fn authorize_working_directory_repository_access(
&self,
request: &WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), WorkingDirectoryDiagnostic> {
self.repository_accesses
.lock()
.unwrap()
.push(request.clone());
if self.repository_access_available.load(Ordering::SeqCst) {
Ok(())
} else {
Err(WorkingDirectoryDiagnostic::rejected(
"working_directory_repository_access_unsupported",
"Worker execution backend does not support Repository access authorization",
))
}
}
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
self.run_generations
.lock()
@@ -3343,6 +3770,24 @@ mod tests {
}
}
struct TestRepositoryResourceClient {
response: Mutex<Option<crate::resource::BackendResourceFetchResponse>>,
}
#[async_trait]
impl BackendResourceClient for TestRepositoryResourceClient {
async fn fetch_resource(
&self,
_request: BackendResourceFetchRequest,
) -> Result<crate::resource::BackendResourceFetchResponse, BackendResourceError> {
self.response
.lock()
.unwrap()
.take()
.ok_or(BackendResourceError::MissingResource)
}
}
fn runtime_with_backend() -> Runtime {
let runtime = Runtime::with_execution_backend(
RuntimeOptions::default(),
+66 -25
View File
@@ -20,7 +20,7 @@ use crate::auth::{
};
use crate::catalog::{
CreateWorkerRequest, ProfileSourceArchiveHttpRef, ProfileSourceArchiveSource,
WorkingDirectoryRequest, WorkingDirectoryStatus,
WorkingDirectoryRepositoryAccessRequest, WorkingDirectoryRequest, WorkingDirectoryStatus,
};
use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation,
@@ -38,9 +38,7 @@ use crate::working_directory::{
};
use async_trait::async_trait;
use protocol::{Event, Method, Segment, WorkerStatus};
use session_store::{
CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore, collect_state,
};
use session_store::{CombinedStore, LogEntry, WorkerAggregateStore, WorkerSessionStore};
#[cfg(test)]
use session_store::{FsStore, FsWorkerStore};
use tokio::runtime::Runtime;
@@ -68,8 +66,10 @@ const RUNTIME_TASK_TIMEOUT: Duration = Duration::from_secs(10);
const USER_INPUT_COMMIT_TIMEOUT: Duration = Duration::from_secs(9);
fn user_input_has_submission(entry: &LogEntry, submission_id: &str) -> bool {
let LogEntry::UserInput { extensions, .. } = entry else {
return false;
let extensions = match entry {
LogEntry::UserInput { extensions, .. }
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
_ => return false,
};
extensions.iter().any(|extension| {
extension.domain == WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN
@@ -212,11 +212,11 @@ impl WorkerObservationProvider for RuntimeGrantedWorkerObservationProvider {
return Err(WorkerObservationError::NotFound);
}
let entries = sink.subscribe_with_snapshot().0;
let state = collect_state(&entries);
Ok(WorkerSessionCapture {
segment_id: format!("runtime:{runtime_id}:worker:{worker_id}"),
items: state.history,
})
WorkerSessionCapture::from_log_entries(
format!("runtime:{runtime_id}:worker:{worker_id}"),
&entries,
)
.map_err(WorkerObservationError::Unavailable)
}
}
@@ -706,13 +706,17 @@ fn runtime_local_workdir_session(
root: &Path,
cwd: &Path,
scope: manifest::SharedScope,
command_environment: std::collections::BTreeMap<String, String>,
resources: Vec<Arc<dyn workdir::WorkdirSessionResource>>,
) -> WorkdirSessionHandle {
Arc::new(LocalWorkdirSession::materialized_bound(
Arc::new(LocalWorkdirSession::materialized_bound_with_environment(
Workdir::new(workdir_id),
root.to_path_buf(),
cwd.to_path_buf(),
scope,
WorkdirSessionCapabilities::ALL,
command_environment,
resources,
))
}
@@ -893,6 +897,8 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
binding.root(),
binding.cwd(),
worker.scope().clone(),
binding.command_environment(),
binding.session_resources(),
)));
} else {
worker.bind_workdir_session(None);
@@ -1071,6 +1077,8 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
binding.root(),
binding.cwd(),
worker.scope().clone(),
binding.command_environment(),
binding.session_resources(),
)));
} else {
worker.bind_workdir_session(None);
@@ -1538,7 +1546,9 @@ fn accepted_notify_run_state(status: WorkerStatus, auto_run: bool) -> WorkerExec
match status {
WorkerStatus::Running => WorkerExecutionRunState::Busy,
WorkerStatus::Idle if auto_run => WorkerExecutionRunState::Busy,
WorkerStatus::Idle | WorkerStatus::Paused => WorkerExecutionRunState::Idle,
WorkerStatus::Idle | WorkerStatus::Paused | WorkerStatus::Stopped => {
WorkerExecutionRunState::Idle
}
}
}
@@ -1582,6 +1592,19 @@ where
Ok(materializer.create(request)?.status())
}
fn authorize_working_directory_repository_access(
&self,
request: &WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), WorkingDirectoryDiagnostic> {
let materializer = self.working_directory_materializer.as_ref().ok_or_else(|| {
WorkingDirectoryDiagnostic::rejected(
"working_directory_materializer_unavailable",
"working directory Repository access requested, but no materializer is configured for this runtime backend",
)
})?;
materializer.authorize_repository_access(request)
}
fn list_working_directories(&self) -> Vec<WorkingDirectoryStatus> {
self.working_directory_materializer
.as_ref()
@@ -1624,6 +1647,8 @@ where
binding.root(),
binding.cwd(),
manifest::SharedScope::new(scope),
binding.command_environment(),
binding.session_resources(),
))
}
@@ -2142,7 +2167,7 @@ mod tests {
use crate::identity::WorkerRef;
use crate::management::RuntimeOptions;
use crate::observation::WorkerObservationCursor;
use crate::working_directory::LocalGitWorktreeMaterializer;
use crate::working_directory::RuntimeGitCacheMaterializer;
use agen::Engine;
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request};
@@ -2482,7 +2507,9 @@ mod tests {
let scope = Scope::writable(&scope_root).map_err(|err| err.to_string())?;
let worker = Worker::new(
manifest,
Engine::new(self.client.clone()),
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
self.client.clone(),
),
store,
workspace_context,
filesystem_authority,
@@ -2728,8 +2755,9 @@ mod tests {
source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")),
},
materializer: MaterializerKind::LocalGitWorktree,
materializer: MaterializerKind::RuntimeGitCache,
backend_workdir_id: None,
materialization: None,
}
}
@@ -2853,12 +2881,16 @@ mod tests {
root.path(),
root.path(),
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
Default::default(),
Vec::new(),
);
let restored = runtime_local_workdir_session(
"working-directory-42",
root.path(),
root.path(),
manifest::SharedScope::new(Scope::writable(root.path()).unwrap()),
Default::default(),
Vec::new(),
);
assert_eq!(spawned.workdir().id().as_str(), "working-directory-42");
@@ -3213,14 +3245,17 @@ mod tests {
matches!(
entry,
LogEntry::UserInput { segments, .. }
| LogEntry::AnnotatedUserInput { segments, .. }
if segments == &vec![Segment::text("start the ticket")]
)
}));
let submission_id = entries
.iter()
.find_map(|entry| {
let LogEntry::UserInput { extensions, .. } = entry else {
return None;
let extensions = match entry {
LogEntry::UserInput { extensions, .. }
| LogEntry::AnnotatedUserInput { extensions, .. } => extensions,
_ => return None,
};
extensions
.iter()
@@ -3330,7 +3365,7 @@ mod tests {
};
let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap()
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(),
));
let runtime =
@@ -3485,7 +3520,7 @@ mod tests {
};
let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap()
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(),
));
let runtime =
@@ -3524,7 +3559,7 @@ mod tests {
let repo = create_clean_repo();
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
.unwrap()
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(),
));
let runtime =
@@ -3560,7 +3595,7 @@ mod tests {
let repo = create_clean_repo();
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
.unwrap()
.with_working_directory_materializer(LocalGitWorktreeMaterializer::new(
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(),
));
let runtime =
@@ -3574,9 +3609,15 @@ mod tests {
assert!(format!("{error:?}").contains("spawn failed"));
let working_directories_root = runtime_base.path();
let remaining_entries = fs::read_dir(working_directories_root)
.map(|entries| entries.count())
let remaining_workdirs = fs::read_dir(working_directories_root)
.map(|entries| {
entries
.flatten()
.filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
.count()
})
.unwrap_or(0);
assert_eq!(remaining_entries, 0);
assert_eq!(remaining_workdirs, 0);
assert!(working_directories_root.join(".repository-cache").is_dir());
}
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -66,11 +66,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
WorkerRunResult::Finished => println!("(finished)"),
WorkerRunResult::Paused => println!("(paused)"),
WorkerRunResult::LimitReached => println!("(turn limit reached)"),
WorkerRunResult::Interrupted { message, .. } => println!("(interrupted: {message})"),
WorkerRunResult::RolledBack => println!("(empty turn rolled back)"),
}
// 5. Extract the assistant's reply from history
let history = worker.engine().history();
let history = worker.history();
if let Some(text) = history
.iter()
.rev()
+1 -1
View File
@@ -22,7 +22,7 @@ use crate::compact::token_counter::{
EstimateSource, savings_for_prune_impl, token_estimates_for_prune_impl,
};
impl<C: LlmClient, St: Store> Worker<C, St> {
impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
/// Enable prune projection on the underlying Engine.
///
/// Registers the config and token/savings-estimator closures on the Engine.
+4 -4
View File
@@ -242,13 +242,13 @@ pub(crate) fn savings_for_prune_impl(
// ── Worker に生やす公開 API ───────────────────────────────────────────────
impl<C: LlmClient, St: Store> Worker<C, St> {
impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
/// 現在の history 全体の推定トークン数。
///
/// 最後の measurement と、その後に追加された未測定分の byte/4 外挿。
pub fn total_tokens(&self) -> TokenEstimate {
let usage = self.usage_history();
agen::token_counter::total_tokens(self.history(), &usage)
agen::token_counter::total_tokens(&self.history(), &usage)
}
/// 任意の history index 時点でのプロンプト全長推定。
@@ -259,7 +259,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
/// pointer 以降に増えたプロンプト長を測るのに使う。
pub fn total_tokens_at(&self, history_len: usize) -> TokenEstimate {
let usage = self.usage_history();
agen::token_counter::total_tokens_at(self.history(), &usage, history_len)
agen::token_counter::total_tokens_at(&self.history(), &usage, history_len)
}
/// 末尾から `retained` トークン以上を残すための分割位置。
@@ -267,7 +267,7 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
/// `history[..cut.index]` が要約/破棄される側、`history[cut.index..]` が残る側。
pub fn split_for_retained(&self, retained: u64) -> SplitPoint {
let usage = self.usage_history();
split_for_retained_impl(self.history(), &usage, retained)
split_for_retained_impl(&self.history(), &usage, retained)
}
}
+63 -315
View File
@@ -31,12 +31,11 @@ use workdir::LocalWorkdirSession;
use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle};
use crate::compact::usage_tracker::UsageTracker;
use crate::fs_view::ReadRequirement;
#[cfg(test)]
use crate::fs_view::slice_lines;
use crate::session_capture::{
ReadDetail, ReadOptions, ReadSelector, SearchOptions, SessionCapture, ToolPart,
use crate::feature::{
FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ToolContribution,
ToolDeclaration,
};
use crate::fs_view::ReadRequirement;
/// Aggregated output of a compact worker run.
#[derive(Debug, Default, Clone)]
@@ -91,248 +90,19 @@ struct SummaryParams {
pub text: String,
}
/// Input to `search_session_log`.
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct SearchSessionParams {
/// Case-insensitive substring to search in compact-target history.
pub query: String,
/// 0-based item offset to start searching from.
#[serde(default)]
pub offset: Option<usize>,
/// Maximum number of hits to return.
#[serde(default)]
pub limit: Option<usize>,
}
/// Input to `read_session_items`.
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ReadSessionParams {
/// 0-based compact-target history item offset.
pub offset: usize,
/// Maximum number of items to return.
pub limit: usize,
/// `compact` omits tool arguments/full results; `full` includes message text and tool result content.
#[serde(default = "default_session_read_mode")]
pub mode: String,
}
fn default_session_read_mode() -> String {
"compact".to_string()
}
const SESSION_TOOL_MAX_OUTPUT_TOKENS: u64 = 12_000;
const SESSION_SEARCH_MAX_RESULTS: usize = 50;
const SESSION_READ_MAX_ITEMS: usize = 80;
const MARK_DESCRIPTION: &str = "Inject a file's contents into the compacted context so the \
next session starts with it already read. Use this for files the next task needs in full. \
Optionally specify `offset` (0-based line) and `limit` (line count) to inject only a slice. \
Counts against `auto_read_budget`; overflow returns an error and the mark is not recorded. \
Paths must be absolute.";
Counts against `auto_read_budget`; overflow returns an error and the mark is not recorded.";
const REFERENCE_DESCRIPTION: &str = "Record a file path as a named reference in the compacted \
context without injecting its contents. Use for files that are contextually relevant but \
whose current content the next session can fetch on demand.";
const REFERENCE_DESCRIPTION: &str = "Record a Workdir-relative file path as a named reference in \
the compacted context without injecting its contents. Use for files that are contextually \
relevant but whose current content the next session can fetch on demand.";
const SUMMARY_DESCRIPTION: &str = "Provide the final structured summary text. Subsequent calls \
replace the previous content; only the last call is used. Must be called before the compact run \
ends or compaction fails.";
const SEARCH_SESSION_DESCRIPTION: &str = "Search the compact-target session history by \
case-insensitive substring. Returns item indexes and compact snippets. Use this when the initial \
overview is not enough to identify which part of the session matters. Results are bounded; narrow \
the query if important details are omitted.";
const READ_SESSION_DESCRIPTION: &str = "Read a bounded range of compact-target session history \
items by 0-based index. mode='compact' omits tool arguments, full tool results, and reasoning \
bodies; mode='full' includes message text and tool result content but still remains bounded. Use \
this to verify details before writing the summary.";
struct SessionLogToolState {
items: Arc<Vec<Item>>,
view: SessionCapture,
}
struct SearchSessionLogTool {
state: Arc<SessionLogToolState>,
}
#[async_trait]
impl Tool for SearchSessionLogTool {
async fn execute(
&self,
input_json: &str,
_ctx: agen::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: SearchSessionParams = serde_json::from_str(input_json).map_err(|e| {
ToolError::InvalidArgument(format!("invalid search_session_log input: {e}"))
})?;
let query = params.query.trim().to_lowercase();
if query.is_empty() {
return Err(ToolError::InvalidArgument(
"search_session_log query must not be empty".to_string(),
));
}
let offset = params.offset.unwrap_or(0);
let limit = params
.limit
.unwrap_or(20)
.clamp(1, SESSION_SEARCH_MAX_RESULTS);
let hits = self.state.view.search(&SearchOptions {
query: params.query.clone(),
kind: None,
tool_part: None,
tool_name: None,
limit: Some(limit),
min_entry_index: Some(offset as u64),
from: None,
through: None,
offset: 0,
});
let blocks = hits
.iter()
.map(|hit| {
let part = hit
.tool_part
.map(|part| format!(" {part:?}"))
.unwrap_or_default();
let tool = hit
.tool_name
.as_ref()
.map(|name| format!(" {name}"))
.unwrap_or_default();
format!(
"[{} {}{}{} {:?}] {}\n{}",
hit.id,
hit.kind.as_str(),
part,
tool,
hit.entry_range,
hit.label,
hit.summary
)
})
.collect::<Vec<_>>();
let mut content = blocks.join("\n\n");
let truncated = truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS);
let summary = if hits.is_empty() {
format!("No session log hits for {query:?} from item offset {offset}.")
} else if truncated {
format!(
"Found {} session log hit(s) for {query:?}; output truncated. Narrow the query.",
hits.len()
)
} else {
format!("Found {} session log hit(s) for {query:?}.", hits.len())
};
Ok(ToolOutput {
summary,
content: (!content.is_empty()).then_some(content),
attachments: Vec::new(),
})
}
}
struct ReadSessionItemsTool {
state: Arc<SessionLogToolState>,
}
#[async_trait]
impl Tool for ReadSessionItemsTool {
async fn execute(
&self,
input_json: &str,
_ctx: agen::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: ReadSessionParams = serde_json::from_str(input_json).map_err(|e| {
ToolError::InvalidArgument(format!("invalid read_session_items input: {e}"))
})?;
let mode = SessionReadMode::parse(&params.mode)?;
let offset = params.offset.min(self.state.items.len());
let limit = params.limit.clamp(1, SESSION_READ_MAX_ITEMS);
let end = offset.saturating_add(limit).min(self.state.items.len());
let detail = match mode {
SessionReadMode::Compact => ReadDetail::Compact,
SessionReadMode::Full => ReadDetail::Full,
};
let read = if offset >= end {
crate::session_capture::ReadResult {
entries: Vec::new(),
truncated: false,
}
} else {
self.state.view.read(
ReadSelector::EntryRange([offset as u64, end.saturating_sub(1) as u64]),
ReadOptions {
include_tools: true,
tool_part: ToolPart::Both,
detail,
max_items: limit,
max_bytes: 48 * 1024,
},
)
};
let mut content = read
.entries
.iter()
.map(|entry| entry.text.clone())
.collect::<Vec<_>>()
.join("\n\n");
let token_truncated =
truncate_to_token_budget(&mut content, SESSION_TOOL_MAX_OUTPUT_TOKENS);
let truncated = read.truncated || token_truncated;
let summary = if truncated {
format!(
"Read session items {offset}..{end} in {mode:?} mode; output truncated. Narrow the range."
)
} else {
format!("Read session items {offset}..{end} in {mode:?} mode.")
};
Ok(ToolOutput {
summary,
content: (!content.is_empty()).then_some(content),
attachments: Vec::new(),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SessionReadMode {
Compact,
Full,
}
impl SessionReadMode {
fn parse(value: &str) -> Result<Self, ToolError> {
match value {
"compact" => Ok(Self::Compact),
"full" => Ok(Self::Full),
other => Err(ToolError::InvalidArgument(format!(
"invalid read_session_items mode {other:?}; expected 'compact' or 'full'"
))),
}
}
}
fn truncate_to_token_budget(text: &mut String, max_tokens: u64) -> bool {
let max_bytes = max_tokens.saturating_mul(4) as usize;
if text.len() <= max_bytes {
return false;
}
let mut cut = 0;
for (idx, _) in text.char_indices() {
if idx > max_bytes {
break;
}
cut = idx;
}
text.truncate(cut);
text.push_str("\n… [session tool output truncated]");
true
}
struct MarkReadRequiredTool {
session: WorkdirSessionHandle,
ctx: Arc<Mutex<CompactWorkerContext>>,
@@ -508,36 +278,63 @@ pub(crate) fn write_summary_tool(ctx: Arc<Mutex<CompactWorkerContext>>) -> ToolD
})
}
pub(crate) fn search_session_log_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
let view = SessionCapture::new("compact-target", (*items).clone());
let state = Arc::new(SessionLogToolState { items, view });
Arc::new(move || {
let schema = schemars::schema_for!(SearchSessionParams);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("search_session_log")
.description(SEARCH_SESSION_DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(SearchSessionLogTool {
state: state.clone(),
});
(meta, tool)
})
#[derive(Clone)]
pub(crate) struct CompactionOutputFeature {
session: Option<WorkdirSessionHandle>,
tracker: tools::Tracker,
context: Arc<Mutex<CompactWorkerContext>>,
}
pub(crate) fn read_session_items_tool(items: Arc<Vec<Item>>) -> ToolDefinition {
let view = SessionCapture::new("compact-target", (*items).clone());
let state = Arc::new(SessionLogToolState { items, view });
Arc::new(move || {
let schema = schemars::schema_for!(ReadSessionParams);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("read_session_items")
.description(READ_SESSION_DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool {
state: state.clone(),
});
(meta, tool)
})
impl CompactionOutputFeature {
pub(crate) fn new(
session: Option<WorkdirSessionHandle>,
tracker: tools::Tracker,
context: Arc<Mutex<CompactWorkerContext>>,
) -> Self {
Self {
session,
tracker,
context,
}
}
}
impl FeatureModule for CompactionOutputFeature {
fn descriptor(&self) -> FeatureDescriptor {
let descriptor = FeatureDescriptor::builtin("compaction-output", "Compaction Output")
.with_description("Read-only Workdir access and bounded compaction output decisions.")
.with_tool(ToolDeclaration::new("add_reference", REFERENCE_DESCRIPTION))
.with_tool(ToolDeclaration::new("write_summary", SUMMARY_DESCRIPTION));
if self.session.is_some() {
descriptor
.with_tool(ToolDeclaration::new("Read", "Read a Workdir file."))
.with_tool(ToolDeclaration::new("mark_read_required", MARK_DESCRIPTION))
} else {
descriptor
}
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
if let Some(session) = &self.session {
context.tools().register(ToolContribution::new(
"Read",
tools::read_tool(session.clone(), self.tracker.clone()),
))?;
context.tools().register(ToolContribution::new(
"mark_read_required",
mark_read_required_tool(session.clone(), self.context.clone()),
))?;
}
context.tools().register(ToolContribution::new(
"add_reference",
add_reference_tool(self.context.clone()),
))?;
context.tools().register(ToolContribution::new(
"write_summary",
write_summary_tool(self.context.clone()),
))?;
Ok(())
}
}
/// Interceptor that monitors compact-worker context occupancy.
@@ -810,53 +607,4 @@ mod tests {
assert_eq!(guard.references.len(), 1);
assert_eq!(guard.references[0], PathBuf::from(p));
}
#[tokio::test]
async fn search_session_log_returns_bounded_hits_without_full_tool_content() {
let items = Arc::new(vec![
Item::user_message("investigate compact failure"),
Item::tool_result_with_content(
"call-1",
"read trace with compact failure",
"very large raw trace body with secret detail",
),
]);
let view = SessionCapture::new("test", (*items).clone());
let tool: Arc<dyn Tool> = Arc::new(SearchSessionLogTool {
state: Arc::new(SessionLogToolState { items, view }),
});
let input = serde_json::json!({ "query": "compact", "limit": 10 }).to_string();
let out = tool.execute(&input, Default::default()).await.unwrap();
let content = out.content.unwrap();
assert!(content.contains("investigate compact failure"));
assert!(content.contains("read trace with compact failure"));
assert!(!content.contains("secret detail"));
}
#[tokio::test]
async fn read_session_items_full_mode_can_read_tool_result_content() {
let items = Arc::new(vec![Item::tool_result_with_content(
"call-1",
"read trace",
"raw trace detail",
)]);
let view = SessionCapture::new("test", (*items).clone());
let tool: Arc<dyn Tool> = Arc::new(ReadSessionItemsTool {
state: Arc::new(SessionLogToolState { items, view }),
});
let input = serde_json::json!({ "offset": 0, "limit": 1, "mode": "full" }).to_string();
let out = tool.execute(&input, Default::default()).await.unwrap();
let content = out.content.unwrap();
assert!(content.contains("raw trace detail"));
}
#[test]
fn slice_lines_handles_offset_and_limit() {
let text = "a\nb\nc\nd";
assert_eq!(slice_lines(text, 0, None), "a\nb\nc\nd");
assert_eq!(slice_lines(text, 1, Some(2)), "b\nc");
assert_eq!(slice_lines(text, 10, None), "");
}
}
+187 -28
View File
@@ -383,6 +383,7 @@ impl WorkerController {
worker.attach_alerter(alerter.clone());
// Also hand the raw broadcast sender so Worker-internal operations
// can emit typed lifecycle `Event`s (currently: compact progress).
worker.attach_internal_worker_registry(spawned_registry.clone());
worker.attach_event_tx(event_tx.clone());
// Bash spills long outputs to a per-worker subdir under the runtime
@@ -421,7 +422,6 @@ impl WorkerController {
wire_event_bridges_on_engine(&mut worker, &event_tx, &alerter, &in_flight);
// === 3. Tool registration (builtin / memory / spawn-orchestration) ===
spawned_registry.attach_parent_protocol(event_tx.clone(), worker.session_id().to_string());
let fs_for_view = register_worker_tools(
&mut worker,
bash_output_dir,
@@ -485,6 +485,7 @@ impl WorkerController {
// into the controller task so the in-flight turn can be reached
// via these handles while worker itself is borrowed by drive_turn.
let cancel_tx = worker.engine_mut().cancel_sender();
let pause_tx = worker.engine_mut().pause_sender();
let notify_buffer = worker.notify_buffer_handle();
tokio::spawn(controller_loop(
@@ -494,6 +495,7 @@ impl WorkerController {
shared_state,
runtime_dir,
cancel_tx,
pause_tx,
notify_buffer,
self_parent_socket,
spawner_name,
@@ -763,6 +765,19 @@ pub(crate) fn wire_event_bridges_on_engine<C, St>(
id: result.tool_use_id.clone(),
summary: result.summary.clone(),
output: result.content.clone(),
disposition: Some(match result.disposition {
agen::ToolResultDisposition::Success => protocol::ToolResultDisposition::Success,
agen::ToolResultDisposition::Error => protocol::ToolResultDisposition::Error,
agen::ToolResultDisposition::Interrupted => {
protocol::ToolResultDisposition::Interrupted
}
agen::ToolResultDisposition::Cancelled => {
protocol::ToolResultDisposition::Cancelled
}
agen::ToolResultDisposition::OutcomeUnknown => {
protocol::ToolResultDisposition::OutcomeUnknown
}
}),
is_error: result.is_error,
});
});
@@ -873,6 +888,14 @@ where
}
let mut feature_registry = FeatureRegistryBuilder::new();
if feature_config.sub_worker.enabled {
feature_registry.add_module(
crate::feature::builtin::manage_worker::sub_worker_control_feature(
worker.workspace_client_handle(),
spawned_registry.clone(),
),
);
}
if feature_config.task.enabled {
feature_registry.add_module(task_feature);
}
@@ -975,14 +998,6 @@ where
if feature_config.sub_worker.enabled {
worker.register_worker_orchestration_instruction();
if !feature_config.worker.enabled {
feature_registry.add_module(
crate::feature::builtin::manage_worker::sub_worker_control_feature(
worker.workspace_client_handle(),
spawned_registry.clone(),
),
);
}
}
let host_worker_observation_provider = worker.worker_observation_provider();
@@ -1115,6 +1130,7 @@ async fn controller_loop<C, St>(
shared_state: Arc<WorkerSharedState>,
runtime_dir: Arc<RuntimeDir>,
cancel_tx: mpsc::Sender<()>,
pause_tx: mpsc::Sender<()>,
notify_buffer: NotifyBuffer,
self_parent_socket: Option<PathBuf>,
spawner_name: String,
@@ -1161,22 +1177,35 @@ async fn controller_loop<C, St>(
// clear at run start prevents stale partial output left by an older
// interrupted/error turn from being carried into the next snapshot.
worker.clear_in_flight_events();
set_controller_status(
&shared_state,
&runtime_dir,
&event_tx,
WorkerStatus::Running,
)
.await;
let parent_originated = run.is_parent_originated();
let user_input_run = matches!(&run, PendingRun::Run(_) | PendingRun::RunTracked { .. });
if !user_input_run {
set_controller_status(
&shared_state,
&runtime_dir,
&event_tx,
WorkerStatus::Running,
)
.await;
}
let (mut new_status, shutdown) = match run {
PendingRun::Run(input) => {
let (input_commit_tx, input_commit_rx) = oneshot::channel();
drive_turn(
worker.run(input),
worker.run_with_input_extensions_and_commit_hook(
input,
Vec::new(),
move || {
let _ = input_commit_tx.send(());
},
),
&mut method_rx,
&event_tx,
&cancel_tx,
&pause_tx,
&shared_state,
&runtime_dir,
Some(input_commit_rx),
&notify_buffer,
self_parent_socket.as_ref(),
&spawner_name,
@@ -1186,12 +1215,22 @@ async fn controller_loop<C, St>(
.await
}
PendingRun::RunTracked { input, extension } => {
let (input_commit_tx, input_commit_rx) = oneshot::channel();
drive_turn(
worker.run_with_input_extensions(input, vec![extension]),
worker.run_with_input_extensions_and_commit_hook(
input,
vec![extension],
move || {
let _ = input_commit_tx.send(());
},
),
&mut method_rx,
&event_tx,
&cancel_tx,
&pause_tx,
&shared_state,
&runtime_dir,
Some(input_commit_rx),
&notify_buffer,
self_parent_socket.as_ref(),
&spawner_name,
@@ -1206,7 +1245,10 @@ async fn controller_loop<C, St>(
&mut method_rx,
&event_tx,
&cancel_tx,
&pause_tx,
&shared_state,
&runtime_dir,
None,
&notify_buffer,
self_parent_socket.as_ref(),
&spawner_name,
@@ -1221,7 +1263,10 @@ async fn controller_loop<C, St>(
&mut method_rx,
&event_tx,
&cancel_tx,
&pause_tx,
&shared_state,
&runtime_dir,
None,
&notify_buffer,
self_parent_socket.as_ref(),
&spawner_name,
@@ -1346,7 +1391,7 @@ async fn controller_loop<C, St>(
});
}
},
WorkerStatus::Idle => {
WorkerStatus::Idle | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error {
code: ErrorCode::NotRunning,
message: "Worker is not running".into(),
@@ -1387,7 +1432,7 @@ async fn controller_loop<C, St>(
.into(),
});
}
WorkerStatus::Running => {
WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning,
message:
@@ -1401,7 +1446,7 @@ async fn controller_loop<C, St>(
WorkerStatus::Idle | WorkerStatus::Paused => {
emit_rewind_targets(&worker, &event_tx)
}
WorkerStatus::Running => {
WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind can only run while idle or paused"
@@ -1430,7 +1475,7 @@ async fn controller_loop<C, St>(
.into(),
});
}
WorkerStatus::Running => {
WorkerStatus::Running | WorkerStatus::Stopped => {
let _ = event_tx.send(Event::Error {
code: ErrorCode::AlreadyRunning,
message: "Worker is already executing a turn; rewind can only run while idle or paused"
@@ -1618,7 +1663,10 @@ async fn drive_turn<F>(
method_rx: &mut mpsc::Receiver<Method>,
event_tx: &broadcast::Sender<Event>,
cancel_tx: &mpsc::Sender<()>,
pause_tx: &mpsc::Sender<()>,
shared_state: &Arc<WorkerSharedState>,
runtime_dir: &RuntimeDir,
mut input_commit_rx: Option<oneshot::Receiver<()>>,
notify_buffer: &NotifyBuffer,
parent_socket: Option<&PathBuf>,
self_name: &str,
@@ -1634,14 +1682,58 @@ where
loop {
tokio::select! {
// If input commit and provider completion become ready together, expose
// Running only after processing the commit fence. This makes the
// Running snapshot contract deterministic even for immediate clients.
biased;
committed = async {
input_commit_rx
.as_mut()
.expect("input commit receiver guarded by select condition")
.await
}, if input_commit_rx.is_some() => {
input_commit_rx = None;
if committed.is_ok() {
set_controller_status(
shared_state,
runtime_dir,
event_tx,
WorkerStatus::Running,
)
.await;
}
}
result = &mut worker_future => {
return match result {
Ok(r) => {
let (status, run_result) = match r {
WorkerRunResult::Finished if pause_requested => {
(WorkerStatus::Paused, RunResult::Paused)
}
WorkerRunResult::Finished => (WorkerStatus::Idle, RunResult::Finished),
WorkerRunResult::Paused => (WorkerStatus::Paused, RunResult::Paused),
WorkerRunResult::LimitReached => (WorkerStatus::Idle, RunResult::LimitReached),
WorkerRunResult::RolledBack => (WorkerStatus::Idle, RunResult::RolledBack),
WorkerRunResult::Interrupted { .. } if pause_requested => {
let _ = event_tx.send(Event::RunEnd { result: RunResult::Paused });
return (WorkerStatus::Paused, shutdown_requested);
}
WorkerRunResult::Interrupted { code, message } => {
let _ = event_tx.send(Event::Error {
code,
message: message.clone(),
});
if parent_originated {
crate::ipc::event::fire_and_forget(
parent_socket.cloned(),
protocol::WorkerEvent::Errored {
worker_name: self_name.to_string(),
message,
},
);
}
return (WorkerStatus::Idle, shutdown_requested);
}
};
let _ = event_tx.send(Event::RunEnd { result: run_result });
if parent_originated && matches!(run_result, RunResult::Finished) {
@@ -1690,7 +1782,7 @@ where
}
Some(Method::Pause) => {
pause_requested = true;
let _ = cancel_tx.try_send(());
let _ = pause_tx.try_send(());
}
Some(Method::Shutdown) => {
shutdown_requested = true;
@@ -1752,7 +1844,7 @@ where
fn emit_rewind_targets<C, St>(worker: &Worker<C, St>, event_tx: &broadcast::Sender<Event>)
where
C: LlmClient,
C: LlmClient + 'static,
St: Store,
{
match worker.list_rewind_targets() {
@@ -1778,7 +1870,7 @@ fn apply_rewind<C, St>(
expected_head_entries: usize,
) -> bool
where
C: LlmClient,
C: LlmClient + 'static,
St: Store,
{
match worker.rewind_to(target, expected_head_entries) {
@@ -1826,7 +1918,7 @@ fn model_supports_image_attachments(model: &manifest::ModelManifest) -> bool {
fn build_greeting<C, St>(worker: &Worker<C, St>) -> protocol::Greeting
where
C: LlmClient,
C: LlmClient + 'static,
St: Store,
{
let manifest = worker.manifest();
@@ -1942,11 +2034,13 @@ mod tests {
event_tx: broadcast::Sender<Event>,
cancel_tx: mpsc::Sender<()>,
_cancel_rx: mpsc::Receiver<()>,
pause_tx: mpsc::Sender<()>,
_pause_rx: mpsc::Receiver<()>,
shared_state: Arc<WorkerSharedState>,
notify_buffer: NotifyBuffer,
spawned_registry: Arc<SpawnedWorkerRegistry>,
parent_socket_path: PathBuf,
_runtime_dir: Arc<RuntimeDir>,
runtime_dir: Arc<RuntimeDir>,
_temp: TempDir,
}
@@ -1960,6 +2054,7 @@ mod tests {
let (method_tx, method_rx) = mpsc::channel::<Method>(16);
let (event_tx, _) = broadcast::channel::<Event>(16);
let (cancel_tx, cancel_rx) = mpsc::channel::<()>(1);
let (pause_tx, pause_rx) = mpsc::channel::<()>(1);
let shared_state = Arc::new(WorkerSharedState::new(
"child-worker".to_string(),
session_store::new_segment_id(),
@@ -1985,11 +2080,13 @@ mod tests {
event_tx,
cancel_tx,
_cancel_rx: cancel_rx,
pause_tx,
_pause_rx: pause_rx,
shared_state,
notify_buffer,
spawned_registry,
parent_socket_path,
_runtime_dir: runtime_dir,
runtime_dir,
_temp: temp,
}
}
@@ -2042,7 +2139,10 @@ mod tests {
&mut env.method_rx,
&env.event_tx,
&env.cancel_tx,
&env.pause_tx,
&env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer,
Some(&env.parent_socket_path),
"child-worker",
@@ -2063,6 +2163,44 @@ mod tests {
}
}
#[tokio::test]
async fn pause_waits_for_run_boundary_and_uses_safe_pause_channel() {
let mut env = make_env().await;
let method_tx = env._method_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(10)).await;
method_tx.send(Method::Pause).await.expect("send pause");
});
let worker_future = async {
tokio::time::sleep(Duration::from_millis(100)).await;
Ok::<_, WorkerError>(WorkerRunResult::Finished)
};
let started_at = std::time::Instant::now();
let (status, shutdown) = drive_turn(
worker_future,
&mut env.method_rx,
&env.event_tx,
&env.cancel_tx,
&env.pause_tx,
&env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer,
None,
"child-worker",
&env.spawned_registry,
true,
)
.await;
assert_eq!(status, WorkerStatus::Paused);
assert!(!shutdown);
assert!(started_at.elapsed() >= Duration::from_millis(100));
assert!(env._pause_rx.try_recv().is_ok());
assert!(env._cancel_rx.try_recv().is_err());
}
#[tokio::test]
async fn non_parent_originated_finished_stays_silent() {
let mut env = make_env().await;
@@ -2074,7 +2212,10 @@ mod tests {
&mut env.method_rx,
&env.event_tx,
&env.cancel_tx,
&env.pause_tx,
&env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer,
Some(&env.parent_socket_path),
"child-worker",
@@ -2109,7 +2250,10 @@ mod tests {
&mut env.method_rx,
&env.event_tx,
&env.cancel_tx,
&env.pause_tx,
&env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer,
Some(&env.parent_socket_path),
"child-worker",
@@ -2150,7 +2294,10 @@ mod tests {
&mut env.method_rx,
&env.event_tx,
&env.cancel_tx,
&env.pause_tx,
&env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer,
Some(&env.parent_socket_path),
"child-worker",
@@ -2189,7 +2336,10 @@ mod tests {
&mut env.method_rx,
&env.event_tx,
&env.cancel_tx,
&env.pause_tx,
&env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer,
Some(&env.parent_socket_path),
"parent",
@@ -2225,7 +2375,10 @@ mod tests {
&mut env.method_rx,
&env.event_tx,
&env.cancel_tx,
&env.pause_tx,
&env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer,
Some(&env.parent_socket_path),
"parent",
@@ -2259,7 +2412,10 @@ mod tests {
&mut env.method_rx,
&env.event_tx,
&env.cancel_tx,
&env.pause_tx,
&env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer,
Some(&env.parent_socket_path),
"parent",
@@ -2292,7 +2448,10 @@ mod tests {
&mut env.method_rx,
&env.event_tx,
&env.cancel_tx,
&env.pause_tx,
&env.shared_state,
&env.runtime_dir,
None,
&env.notify_buffer,
Some(&env.parent_socket_path),
"child-worker",
+634 -85
View File
@@ -12,7 +12,7 @@
//! ordinary feature reports/diagnostics instead of a separate authority layer.
use std::any::{Any, type_name};
use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::fmt;
use std::sync::Arc;
@@ -447,6 +447,16 @@ impl ServiceVersionReq {
requirement: "*".into(),
}
}
pub fn exact(version: impl Into<String>) -> Self {
Self {
requirement: version.into(),
}
}
fn matches(&self, provider_version: &str) -> bool {
self.requirement == "*" || self.requirement == provider_version
}
}
/// Feature-provided service declaration. This first slice records provider
@@ -1430,30 +1440,34 @@ impl FeatureInstallContext<'_> {
pub struct FeatureRegistryInstallReport {
pub reports: Vec<FeatureInstallReport>,
pub services: FeatureServiceRegistry,
pub plan_error: Option<FeaturePlanError>,
}
impl FeatureRegistryInstallReport {
pub fn has_errors(&self) -> bool {
self.reports.iter().any(|report| {
report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
})
}
pub fn error_message(&self) -> String {
self.reports
.iter()
.flat_map(|report| {
self.plan_error.is_some()
|| self.reports.iter().any(|report| {
report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
.map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message))
.any(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
})
.collect::<Vec<_>>()
.join("; ")
}
pub fn error_message(&self) -> String {
let mut errors = self
.plan_error
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>();
errors.extend(self.reports.iter().flat_map(|report| {
report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.severity == FeatureDiagnosticSeverity::Error)
.map(move |diagnostic| format!("{}: {}", report.feature_id, diagnostic.message))
}));
errors.join("; ")
}
pub fn installed_tool_names(&self) -> Vec<String> {
@@ -1485,12 +1499,265 @@ pub fn dedupe_instruction_contributions(
deduped
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PlannedServiceProvider {
pub service: ServiceId,
pub provider: FeatureId,
pub version: String,
}
/// A validated, deterministic installation order and its selected service providers.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FeatureInstallPlan {
ordered_features: Vec<FeatureId>,
service_providers: BTreeMap<ServiceId, PlannedServiceProvider>,
ordered_indices: Vec<usize>,
}
impl FeatureInstallPlan {
pub fn ordered_features(&self) -> &[FeatureId] {
&self.ordered_features
}
pub fn service_providers(&self) -> &BTreeMap<ServiceId, PlannedServiceProvider> {
&self.service_providers
}
}
#[derive(Clone, Debug, Eq, PartialEq, Error)]
pub enum FeaturePlanError {
#[error("feature `{feature}` is registered more than once")]
DuplicateFeature { feature: FeatureId },
#[error("service `{service}` has multiple selected providers: {providers:?}")]
AmbiguousServiceProvider {
service: ServiceId,
providers: Vec<FeatureId>,
},
#[error("feature `{consumer}` requires service `{service}`, but no provider is selected")]
MissingServiceProvider {
consumer: FeatureId,
service: ServiceId,
},
#[error(
"feature `{consumer}` requires service `{service}` version `{requirement}`, but provider `{provider}` provides `{provider_version}`"
)]
ServiceVersionMismatch {
consumer: FeatureId,
service: ServiceId,
requirement: String,
provider: FeatureId,
provider_version: String,
},
#[error(
"service dependency cycle involves features {features:?} through services {services:?}"
)]
ServiceDependencyCycle {
features: Vec<FeatureId>,
services: Vec<ServiceId>,
},
}
/// Builder/installer for enabled feature modules.
#[derive(Default)]
pub struct FeatureRegistryBuilder {
modules: Vec<Arc<dyn FeatureModule>>,
}
fn build_feature_install_plan(
descriptors: &[FeatureDescriptor],
) -> Result<FeatureInstallPlan, FeaturePlanError> {
let mut feature_ids = BTreeSet::new();
for descriptor in descriptors {
if !feature_ids.insert(descriptor.id.clone()) {
return Err(FeaturePlanError::DuplicateFeature {
feature: descriptor.id.clone(),
});
}
}
let mut provider_candidates: BTreeMap<ServiceId, Vec<(usize, ServiceDeclaration)>> =
BTreeMap::new();
for (index, descriptor) in descriptors.iter().enumerate() {
for service in &descriptor.provides_services {
provider_candidates
.entry(service.id.clone())
.or_default()
.push((index, service.clone()));
}
}
let mut selected_providers = BTreeMap::new();
for (service, candidates) in provider_candidates {
if candidates.len() > 1 {
let mut providers = candidates
.iter()
.map(|(index, _)| descriptors[*index].id.clone())
.collect::<Vec<_>>();
providers.sort();
return Err(FeaturePlanError::AmbiguousServiceProvider { service, providers });
}
let (provider_index, declaration) = candidates
.into_iter()
.next()
.expect("provider candidates are non-empty");
selected_providers.insert(service, (provider_index, declaration));
}
let active = (0..descriptors.len()).collect::<BTreeSet<_>>();
let mut adjacency = vec![BTreeSet::new(); descriptors.len()];
let mut indegree = vec![0usize; descriptors.len()];
let mut edge_services: BTreeMap<(usize, usize), BTreeSet<ServiceId>> = BTreeMap::new();
for &consumer_index in &active {
let consumer = &descriptors[consumer_index];
for requirement in &consumer.requires_services {
let provider = selected_providers.get(&requirement.id);
let Some((provider_index, declaration)) = provider else {
if requirement.required {
return Err(FeaturePlanError::MissingServiceProvider {
consumer: consumer.id.clone(),
service: requirement.id.clone(),
});
}
continue;
};
if !requirement.version.matches(&declaration.version) {
return Err(FeaturePlanError::ServiceVersionMismatch {
consumer: consumer.id.clone(),
service: requirement.id.clone(),
requirement: requirement.version.requirement.clone(),
provider: descriptors[*provider_index].id.clone(),
provider_version: declaration.version.clone(),
});
}
if adjacency[*provider_index].insert(consumer_index) {
indegree[consumer_index] += 1;
}
edge_services
.entry((*provider_index, consumer_index))
.or_default()
.insert(requirement.id.clone());
}
}
let mut ready = active
.iter()
.copied()
.filter(|index| indegree[*index] == 0)
.collect::<BTreeSet<_>>();
let mut ordered_indices = Vec::with_capacity(active.len());
while let Some(index) = ready.pop_first() {
ordered_indices.push(index);
for &dependent in &adjacency[index] {
indegree[dependent] -= 1;
if indegree[dependent] == 0 {
ready.insert(dependent);
}
}
}
if ordered_indices.len() != active.len() {
let cycle_indices = find_service_dependency_cycle(&active, &adjacency);
let features = cycle_indices
.iter()
.map(|index| descriptors[*index].id.clone())
.collect::<Vec<_>>();
let mut services = BTreeSet::new();
for edge in cycle_indices.windows(2) {
if let Some(ids) = edge_services.get(&(edge[0], edge[1])) {
services.extend(ids.iter().cloned());
}
}
if let (Some(first), Some(last)) = (cycle_indices.first(), cycle_indices.last())
&& let Some(ids) = edge_services.get(&(*last, *first))
{
services.extend(ids.iter().cloned());
}
return Err(FeaturePlanError::ServiceDependencyCycle {
features,
services: services.into_iter().collect(),
});
}
let service_providers = selected_providers
.into_iter()
.map(|(service, (provider_index, declaration))| {
let planned = PlannedServiceProvider {
service: service.clone(),
provider: descriptors[provider_index].id.clone(),
version: declaration.version,
};
(service, planned)
})
.collect();
let ordered_features = ordered_indices
.iter()
.map(|index| descriptors[*index].id.clone())
.collect();
Ok(FeatureInstallPlan {
ordered_features,
service_providers,
ordered_indices,
})
}
fn find_service_dependency_cycle(
active: &BTreeSet<usize>,
adjacency: &[BTreeSet<usize>],
) -> Vec<usize> {
fn visit(
index: usize,
active: &BTreeSet<usize>,
adjacency: &[BTreeSet<usize>],
state: &mut [u8],
stack: &mut Vec<usize>,
) -> Option<Vec<usize>> {
state[index] = 1;
stack.push(index);
for &next in &adjacency[index] {
if !active.contains(&next) {
continue;
}
match state[next] {
0 => {
if let Some(cycle) = visit(next, active, adjacency, state, stack) {
return Some(cycle);
}
}
1 => {
let start = stack
.iter()
.position(|candidate| *candidate == next)
.expect("visiting node is present in DFS stack");
return Some(stack[start..].to_vec());
}
_ => {}
}
}
stack.pop();
state[index] = 2;
None
}
let mut state = vec![0u8; adjacency.len()];
let mut stack = Vec::new();
for &index in active {
if state[index] == 0
&& let Some(cycle) = visit(index, active, adjacency, &mut state, &mut stack)
{
return cycle;
}
}
Vec::new()
}
impl Default for FeatureRegistryBuilder {
fn default() -> Self {
Self {
modules: Vec::new(),
}
}
}
impl FeatureRegistryBuilder {
pub fn new() -> Self {
Self::default()
@@ -1523,10 +1790,14 @@ impl FeatureRegistryBuilder {
.collect()
}
pub fn plan(&self) -> Result<FeatureInstallPlan, FeaturePlanError> {
build_feature_install_plan(&self.descriptors())
}
/// Install modules into the existing Engine tool path and hook builder.
pub(crate) fn install_into_engine<C: LlmClient>(
pub(crate) fn install_into_engine<C: LlmClient, A>(
self,
worker: &mut Engine<C, Mutable>,
worker: &mut Engine<C, Mutable, A>,
hook_builder: &mut HookRegistryBuilder,
) -> FeatureRegistryInstallReport {
let mut pending_tools = Vec::new();
@@ -1568,53 +1839,52 @@ impl FeatureRegistryBuilder {
.iter()
.map(|module| module.descriptor())
.collect();
let mut service_registry = FeatureServiceRegistry::default();
let mut reports = Vec::with_capacity(self.modules.len());
let mut seen_features = HashSet::new();
let mut pending_modules: Vec<_> = self.modules.into_iter().zip(descriptors).collect();
let mut ordered_modules = Vec::with_capacity(pending_modules.len());
let mut declared_services = HashSet::new();
while !pending_modules.is_empty() {
let next = pending_modules
.iter()
.position(|(_, descriptor)| {
descriptor
.requires_services
.iter()
.filter(|requirement| requirement.required)
.all(|requirement| declared_services.contains(&requirement.id))
})
.unwrap_or(0);
let entry = pending_modules.remove(next);
declared_services.extend(
entry
.1
.provides_services
let plan = match build_feature_install_plan(&descriptors) {
Ok(plan) => plan,
Err(error) => {
let message = error.to_string();
let reports = descriptors
.iter()
.map(|service| service.id.clone()),
);
ordered_modules.push(entry);
}
.map(|descriptor| {
let mut report = FeatureInstallReport::new(descriptor);
report
.diagnostics
.push(FeatureDiagnostic::error(message.clone()));
report.mark_skipped(
FeatureContributionKind::Diagnostic,
descriptor.id.to_string(),
"feature installation plan rejected before installation",
);
report
})
.collect();
return FeatureRegistryInstallReport {
reports,
services: FeatureServiceRegistry::default(),
plan_error: Some(error),
};
}
};
let mut service_registry = FeatureServiceRegistry::default();
let mut reports = Vec::with_capacity(plan.ordered_indices.len());
let mut modules = self.modules.into_iter().map(Some).collect::<Vec<_>>();
let ordered_modules = plan
.ordered_indices
.into_iter()
.map(|index| {
(
modules[index]
.take()
.expect("planned feature module is selected exactly once"),
descriptors[index].clone(),
)
})
.collect::<Vec<_>>();
for (module, descriptor) in ordered_modules {
let declarations = FeatureContributionDeclarations::from_descriptor(&descriptor);
let mut report = FeatureInstallReport::new(&descriptor);
if !seen_features.insert(descriptor.id.clone()) {
report.diagnostics.push(FeatureDiagnostic::error(format!(
"duplicate feature id: {}",
descriptor.id
)));
report.mark_skipped(
FeatureContributionKind::Diagnostic,
descriptor.id.to_string(),
"duplicate feature id",
);
reports.push(report);
continue;
}
let mut required_service_failed = false;
for requirement in descriptor.requires_services.iter().cloned() {
if service_registry.provides(&requirement.id) {
@@ -1681,6 +1951,7 @@ impl FeatureRegistryBuilder {
FeatureRegistryInstallReport {
reports,
services: service_registry,
plan_error: None,
}
}
}
@@ -1821,6 +2092,59 @@ mod tests {
}
}
struct PlannedServiceFeature {
descriptor: FeatureDescriptor,
install_calls: Arc<AtomicUsize>,
fail_install: bool,
}
impl PlannedServiceFeature {
fn new(descriptor: FeatureDescriptor) -> Self {
Self {
descriptor,
install_calls: Arc::new(AtomicUsize::new(0)),
fail_install: false,
}
}
}
impl FeatureModule for PlannedServiceFeature {
fn descriptor(&self) -> FeatureDescriptor {
self.descriptor.clone()
}
fn install(
&self,
context: &mut FeatureInstallContext<'_>,
) -> Result<(), FeatureInstallError> {
self.install_calls.fetch_add(1, Ordering::SeqCst);
if self.fail_install {
return Err(FeatureInstallError::Install(
"injected provider failure".into(),
));
}
for service in &self.descriptor.provides_services {
context
.services()
.provide(service.clone(), Arc::new(service.id.to_string()))?;
}
Ok(())
}
}
fn provided_service(id: &'static str, version: &'static str) -> ServiceDeclaration {
ServiceDeclaration::new(ServiceId::builtin(id), version, "test service")
}
fn required_service(id: &'static str, version: ServiceVersionReq) -> ServiceRequirement {
{
let mut requirement =
ServiceRequirement::required(ServiceId::builtin(id), "test dependency");
requirement.version = version;
requirement
}
}
fn instruction(id: &'static str, prompt_ref: &'static str) -> FeatureInstructionDeclaration {
FeatureInstructionDeclaration::new(
FeatureInstructionId::builtin(id),
@@ -1830,6 +2154,249 @@ mod tests {
.unwrap()
}
#[test]
fn install_plan_orders_provider_before_consumer_stably() {
let consumer = PlannedServiceFeature::new(
FeatureDescriptor::builtin("consumer", "Consumer")
.with_service_requirement(required_service("catalog", ServiceVersionReq::any())),
);
let unrelated =
PlannedServiceFeature::new(FeatureDescriptor::builtin("unrelated", "Unrelated"));
let provider = PlannedServiceFeature::new(
FeatureDescriptor::builtin("provider", "Provider")
.with_provided_service(provided_service("catalog", "1")),
);
let builder = FeatureRegistryBuilder::new()
.with_module(consumer)
.with_module(unrelated)
.with_module(provider);
let plan = builder.plan().expect("service graph should be valid");
assert_eq!(
plan.ordered_features(),
&[
FeatureId::builtin("unrelated"),
FeatureId::builtin("provider"),
FeatureId::builtin("consumer"),
]
);
}
#[test]
fn install_plan_orders_compatible_optional_provider_before_consumer() {
let service = ServiceId::builtin("catalog");
let consumer = PlannedServiceFeature::new(
FeatureDescriptor::builtin("consumer", "Consumer").with_service_requirement(
ServiceRequirement::optional(service.clone(), "optional catalog"),
),
);
let provider = PlannedServiceFeature::new(
FeatureDescriptor::builtin("provider", "Provider")
.with_provided_service(provided_service("catalog", "1")),
);
let builder = FeatureRegistryBuilder::new()
.with_module(consumer)
.with_module(provider);
let plan = builder.plan().expect("optional binding should be valid");
assert_eq!(
plan.ordered_features(),
&[
FeatureId::builtin("provider"),
FeatureId::builtin("consumer"),
]
);
let mut hooks = HookRegistryBuilder::default();
let mut pending_tools = Vec::new();
let report = builder.install_into_pending(&mut pending_tools, &mut hooks);
assert!(!report.has_errors());
let consumer = report
.reports
.iter()
.find(|report| report.feature_id == FeatureId::builtin("consumer"))
.expect("consumer report");
assert!(
consumer
.resolved_service_requirements
.iter()
.any(|requirement| requirement.id == service)
);
}
#[test]
fn install_plan_rejects_missing_required_service() {
let builder = FeatureRegistryBuilder::new().with_module(PlannedServiceFeature::new(
FeatureDescriptor::builtin("consumer", "Consumer")
.with_service_requirement(required_service("missing", ServiceVersionReq::any())),
));
assert_eq!(
builder.plan(),
Err(FeaturePlanError::MissingServiceProvider {
consumer: FeatureId::builtin("consumer"),
service: ServiceId::builtin("missing"),
})
);
}
#[test]
fn install_plan_rejects_ambiguous_provider_independent_of_registration_order() {
fn plan(reverse: bool) -> FeaturePlanError {
let provider_a = PlannedServiceFeature::new(
FeatureDescriptor::builtin("provider-a", "Provider A")
.with_provided_service(provided_service("catalog", "1")),
);
let provider_b = PlannedServiceFeature::new(
FeatureDescriptor::builtin("provider-b", "Provider B")
.with_provided_service(provided_service("catalog", "1")),
);
let mut builder = FeatureRegistryBuilder::new();
if reverse {
builder.add_module(provider_b).add_module(provider_a);
} else {
builder.add_module(provider_a).add_module(provider_b);
}
builder.plan().expect_err("duplicate providers must fail")
}
let expected = FeaturePlanError::AmbiguousServiceProvider {
service: ServiceId::builtin("catalog"),
providers: vec![
FeatureId::builtin("provider-a"),
FeatureId::builtin("provider-b"),
],
};
assert_eq!(plan(false), expected);
assert_eq!(plan(true), expected);
}
#[test]
fn install_plan_rejects_service_version_mismatch() {
let builder = FeatureRegistryBuilder::new()
.with_module(PlannedServiceFeature::new(
FeatureDescriptor::builtin("consumer", "Consumer").with_service_requirement(
required_service("catalog", ServiceVersionReq::exact("2")),
),
))
.with_module(PlannedServiceFeature::new(
FeatureDescriptor::builtin("provider", "Provider")
.with_provided_service(provided_service("catalog", "1")),
));
assert_eq!(
builder.plan(),
Err(FeaturePlanError::ServiceVersionMismatch {
consumer: FeatureId::builtin("consumer"),
service: ServiceId::builtin("catalog"),
requirement: "2".into(),
provider: FeatureId::builtin("provider"),
provider_version: "1".into(),
})
);
}
#[test]
fn install_plan_reports_service_cycle_members() {
let builder = FeatureRegistryBuilder::new()
.with_module(PlannedServiceFeature::new(
FeatureDescriptor::builtin("alpha", "Alpha")
.with_provided_service(provided_service("alpha-service", "1"))
.with_service_requirement(required_service(
"beta-service",
ServiceVersionReq::any(),
)),
))
.with_module(PlannedServiceFeature::new(
FeatureDescriptor::builtin("beta", "Beta")
.with_provided_service(provided_service("beta-service", "1"))
.with_service_requirement(required_service(
"alpha-service",
ServiceVersionReq::any(),
)),
));
let error = builder.plan().expect_err("cycle must fail before install");
let FeaturePlanError::ServiceDependencyCycle { features, services } = error else {
panic!("unexpected plan error: {error:?}");
};
assert_eq!(
features.into_iter().collect::<BTreeSet<_>>(),
BTreeSet::from([FeatureId::builtin("alpha"), FeatureId::builtin("beta")])
);
assert_eq!(
services.into_iter().collect::<BTreeSet<_>>(),
BTreeSet::from([
ServiceId::builtin("alpha-service"),
ServiceId::builtin("beta-service"),
])
);
}
#[test]
fn plan_failure_has_no_install_side_effects_and_reports_identities() {
let first = PlannedServiceFeature::new(
FeatureDescriptor::builtin("first", "First")
.with_provided_service(provided_service("control", "1")),
);
let first_calls = Arc::clone(&first.install_calls);
let second = PlannedServiceFeature::new(
FeatureDescriptor::builtin("second", "Second")
.with_provided_service(provided_service("control", "1")),
);
let second_calls = Arc::clone(&second.install_calls);
let mut hook_builder = HookRegistryBuilder::default();
let mut pending_tools = Vec::new();
let report = FeatureRegistryBuilder::new()
.with_module(first)
.with_module(second)
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(report.has_errors());
assert!(matches!(
report.plan_error,
Some(FeaturePlanError::AmbiguousServiceProvider { .. })
));
assert!(report.error_message().contains("builtin:control"));
assert!(report.error_message().contains("builtin:first"));
assert!(report.error_message().contains("builtin:second"));
assert_eq!(first_calls.load(Ordering::SeqCst), 0);
assert_eq!(second_calls.load(Ordering::SeqCst), 0);
assert!(pending_tools.is_empty());
}
#[test]
fn provider_install_failure_is_fatal_and_does_not_queue_tools() {
let provider_calls = Arc::new(AtomicUsize::new(0));
let provider = PlannedServiceFeature {
descriptor: FeatureDescriptor::builtin("provider", "Provider")
.with_provided_service(provided_service("control", "1")),
install_calls: Arc::clone(&provider_calls),
fail_install: true,
};
let consumer = ToolFeature {
descriptor: FeatureDescriptor::builtin("consumer", "Consumer")
.with_service_requirement(required_service("control", ServiceVersionReq::any()))
.with_tool(ToolDeclaration::new("Dependent", "dependent tool")),
contribution_name: "Dependent",
model_visible_name: "Dependent",
};
let mut hook_builder = HookRegistryBuilder::default();
let mut pending_tools = Vec::new();
let report = FeatureRegistryBuilder::new()
.with_module(consumer)
.with_module(provider)
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(report.has_errors());
assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
assert!(pending_tools.is_empty());
assert!(report.error_message().contains("builtin:provider"));
assert!(report.error_message().contains("injected provider failure"));
}
#[test]
fn descriptor_contributions_are_recorded() {
let descriptor = FeatureDescriptor::builtin("dummy", "Dummy")
@@ -2382,10 +2949,6 @@ mod tests {
);
let consumer = FeatureDescriptor::builtin("consumer", "Consumer")
.with_service_requirement(ServiceRequirement::required(service.clone(), "needs demo"));
let missing_service = ServiceId::builtin("missing-service");
let missing = FeatureDescriptor::builtin("missing", "Missing").with_service_requirement(
ServiceRequirement::required(missing_service, "needs missing"),
);
let optional_service = ServiceId::builtin("optional-service");
let optional = FeatureDescriptor::builtin("optional", "Optional").with_service_requirement(
ServiceRequirement::optional(optional_service, "nice to have"),
@@ -2399,9 +2962,6 @@ mod tests {
.with_module(ServiceFeature {
descriptor: consumer,
})
.with_module(ServiceFeature {
descriptor: missing,
})
.with_module(ServiceFeature {
descriptor: optional,
})
@@ -2413,18 +2973,6 @@ mod tests {
report.reports[1].resolved_service_requirements[0].id,
service
);
let missing_report = report
.reports
.iter()
.find(|feature| feature.feature_id == FeatureId::builtin("missing"))
.unwrap();
assert!(!missing_report.installed);
assert!(
missing_report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("required service requirement"))
);
let optional_report = report
.reports
.iter()
@@ -2543,11 +3091,12 @@ mod tests {
.with_module(ServiceFeature { descriptor })
.install_into_pending(&mut pending_tools, &mut hook_builder);
assert!(report.has_errors());
assert!(
report
.error_message()
.contains("required service requirement")
);
assert!(matches!(
report.plan_error,
Some(FeaturePlanError::MissingServiceProvider { .. })
));
assert!(report.error_message().contains("builtin:consumer"));
assert!(report.error_message().contains("builtin:missing-service"));
}
#[test]
+1
View File
@@ -12,6 +12,7 @@ pub mod memory_extract;
pub mod merge_request;
pub mod objective;
pub mod orchestration;
mod resource_projection;
pub mod session_explore;
pub mod task;
pub mod ticket;
@@ -560,6 +560,7 @@ where
attempt.instance_id, attempt.checked_state_revision
)),
max_turns: Some(12),
engine_configurator: None,
features,
required_tools: &[
"ShowOverview",
@@ -571,6 +572,7 @@ where
workspace: WorkerWorkspaceContext::no_workspace(),
filesystem: WorkerFilesystemAuthority::None,
scope: Scope::empty(),
workdir_session: None,
},
};
match run_internal_worker(spec).await {
@@ -356,16 +356,42 @@ pub fn manage_worker_feature(
}
}
#[derive(Clone)]
pub struct SubWorkerControlFeature {
control: Arc<dyn WorkerControlService>,
client: Arc<dyn WorkspaceClient>,
registry: Arc<SpawnedWorkerRegistry>,
}
impl std::fmt::Debug for SubWorkerControlFeature {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("SubWorkerControlFeature")
.finish_non_exhaustive()
impl SubWorkerControlFeature {
pub fn new(client: Arc<dyn WorkspaceClient>, registry: Arc<SpawnedWorkerRegistry>) -> Self {
Self { client, registry }
}
}
impl FeatureModule for SubWorkerControlFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("sub-worker-control", "SubWorker Control")
.with_description("Parent-owned SubWorker control service provider")
.with_provided_service(ServiceDeclaration::new(
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
WORKER_LIFECYCLE_SERVICE_VERSION,
"Parent-owned SubWorker discovery and control operations",
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
let control: Arc<dyn WorkerControlService> = Arc::new(WorkspaceWorkerControlService {
workspace_id: self.client.workspace_id().unwrap_or_default().to_string(),
client: self.client.clone(),
registry: Some(self.registry.clone()),
});
context.services().provide(
ServiceDeclaration::new(
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
WORKER_LIFECYCLE_SERVICE_VERSION,
"Parent-owned SubWorker discovery and control operations",
),
control,
)
}
}
@@ -373,38 +399,7 @@ pub fn sub_worker_control_feature(
client: Arc<dyn WorkspaceClient>,
registry: Arc<SpawnedWorkerRegistry>,
) -> SubWorkerControlFeature {
let workspace_id = client.workspace_id().unwrap_or_default().to_string();
SubWorkerControlFeature {
control: Arc::new(WorkspaceWorkerControlService {
client,
workspace_id,
registry: Some(registry),
}),
}
}
impl FeatureModule for SubWorkerControlFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("sub_worker", "SubWorker")
.with_description("Parent-owned SubWorker control authority.")
.with_provided_service(ServiceDeclaration::new(
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
WORKER_LIFECYCLE_SERVICE_VERSION,
"Known-SubWorker discovery and permission-fenced control operations",
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.services().provide(
ServiceDeclaration::new(
ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
WORKER_LIFECYCLE_SERVICE_VERSION,
"Known-SubWorker discovery and permission-fenced control operations",
),
self.control.clone(),
)?;
Ok(())
}
SubWorkerControlFeature::new(client, registry)
}
impl FeatureModule for ManageWorkerFeature {
@@ -1097,6 +1092,42 @@ mod tests {
assert!(body.get("initial_text").is_none());
}
#[tokio::test]
async fn worker_and_sub_worker_providers_are_ambiguous_before_install() {
let runtime_base = tempfile::tempdir().unwrap();
let runtime_dir = Arc::new(
crate::runtime::dir::RuntimeDir::create(runtime_base.path(), "feature-plan")
.await
.unwrap(),
);
let registry = SpawnedWorkerRegistry::new(runtime_dir);
let client: Arc<dyn WorkspaceClient> = Arc::new(RecordingWorkspaceClient::default());
let mut builder = crate::feature::FeatureRegistryBuilder::new();
builder.add_module(sub_worker_control_feature(client.clone(), registry.clone()));
builder.add_module(manage_worker_feature(client, Some(registry), true));
assert_eq!(
builder.plan(),
Err(crate::feature::FeaturePlanError::AmbiguousServiceProvider {
service: ServiceId::builtin(WORKER_CONTROL_SERVICE_ID),
providers: vec![
crate::feature::FeatureId::builtin("sub-worker-control"),
crate::feature::FeatureId::builtin("worker"),
],
})
);
let mut hooks = crate::HookRegistryBuilder::default();
let mut pending_tools = Vec::new();
let report = builder.install_into_pending(&mut pending_tools, &mut hooks);
assert!(matches!(
report.plan_error,
Some(crate::feature::FeaturePlanError::AmbiguousServiceProvider { .. })
));
assert!(pending_tools.is_empty());
assert!(report.services.providers().is_empty());
}
#[test]
fn worker_service_can_remain_enabled_without_direct_spawn_surface() {
let client = Arc::new(RecordingWorkspaceClient::default());
@@ -6,7 +6,9 @@ use memory::backend::{
MemoryBackendOperation, MemoryBackendOperationResult, MemoryStageCandidateOperation,
};
use memory::extract::{CandidateKind, ExtractedCandidate, StagingEvidence};
use memory::schema::{EvidenceKind, SourceEvidenceRef, SourceRef};
use memory::schema::{
EvidenceKind, EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef, SourceRef,
};
use schemars::JsonSchema;
use serde::Deserialize;
@@ -174,17 +176,29 @@ impl Tool for StageMemoryCandidateTool {
"StageMemoryCandidate requires at least one entry_ref".to_string(),
));
}
let mut evidence = Vec::with_capacity(params.entry_refs.len());
let mut source_refs = Vec::with_capacity(params.entry_refs.len());
let mut entries = Vec::with_capacity(params.entry_refs.len());
for entry_ref in &params.entry_refs {
let projection = self.state.view.evidence_for(entry_ref).ok_or_else(|| {
entries.push(self.state.view.evidence_for(entry_ref).ok_or_else(|| {
ToolError::InvalidArgument(format!(
"unknown SessionEntryRef {entry_ref:?} for this extraction capture"
))
})?;
evidence.push(staging_evidence(&projection));
source_refs.push(source_evidence_ref(&projection));
})?);
}
if matches!(params.kind, CandidateKind::Preference)
&& entries.iter().any(|entry| {
!matches!(
entry.origin,
crate::WorkerHistoryProvenance::HumanInput { .. }
)
})
{
return Err(ToolError::InvalidArgument(
"preference candidates require exclusively HumanInput evidence; model, Worker, Flow, backend, derived, and legacy-unknown origins are not preference authority"
.to_string(),
));
}
let evidence = entries.iter().map(staging_evidence).collect();
let source_refs = entries.iter().map(source_evidence_ref).collect();
let candidate = ExtractedCandidate {
kind: params.kind,
claim: params.claim,
@@ -310,11 +324,65 @@ fn evidence_kind(entry: &SessionEntryEvidence) -> EvidenceKind {
}
}
fn evidence_origin(origin: &crate::WorkerHistoryProvenance) -> EvidenceOrigin {
use crate::WorkerHistoryProvenance as Origin;
let mut evidence = EvidenceOrigin {
kind: EvidenceOriginKind::LegacyUnknown,
account_id: None,
workspace_id: None,
runtime_id: None,
worker_id: None,
flow_selector: None,
flow_definition_id: None,
flow_definition_revision: None,
};
match origin {
Origin::HumanInput { account_id } => {
evidence.kind = EvidenceOriginKind::HumanInput;
evidence.account_id = Some(account_id.clone());
}
Origin::WorkerInput { actor } => {
evidence.kind = EvidenceOriginKind::WorkerInput;
evidence.workspace_id = actor.workspace_id.clone();
evidence.runtime_id = actor.runtime_id.clone();
evidence.worker_id = Some(actor.worker_id.clone());
}
Origin::FlowInstruction {
selector,
definition_id,
definition_revision,
..
} => {
evidence.kind = EvidenceOriginKind::FlowInstruction;
evidence.flow_selector = Some(selector.clone());
evidence.flow_definition_id = Some(definition_id.clone());
evidence.flow_definition_revision = Some(*definition_revision);
}
Origin::BackendInstruction { .. } => evidence.kind = EvidenceOriginKind::BackendInstruction,
Origin::ModelOutput { worker } => {
evidence.kind = EvidenceOriginKind::ModelOutput;
evidence.workspace_id = worker.workspace_id.clone();
evidence.runtime_id = worker.runtime_id.clone();
evidence.worker_id = Some(worker.worker_id.clone());
}
Origin::ToolOutput { worker } => {
evidence.kind = EvidenceOriginKind::ToolOutput;
evidence.workspace_id = worker.workspace_id.clone();
evidence.runtime_id = worker.runtime_id.clone();
evidence.worker_id = Some(worker.worker_id.clone());
}
Origin::DerivedSummary => evidence.kind = EvidenceOriginKind::DerivedSummary,
Origin::LegacyUnknown => evidence.kind = EvidenceOriginKind::LegacyUnknown,
}
evidence
}
fn staging_evidence(entry: &SessionEntryEvidence) -> StagingEvidence {
StagingEvidence {
id: entry.entry_ref.to_string(),
kind: evidence_kind(entry),
entry_range: Some(entry.entry_range),
origin: Some(evidence_origin(&entry.origin)),
excerpt: Some(entry.excerpt.clone()),
summary: Some(entry.summary.clone()),
}
@@ -325,6 +393,7 @@ fn source_evidence_ref(entry: &SessionEntryEvidence) -> SourceEvidenceRef {
segment_id: Some(entry.segment_id.clone()),
entry_range: Some(entry.entry_range),
evidence_id: Some(entry.entry_ref.to_string()),
origin: Some(evidence_origin(&entry.origin)),
evidence_kind: Some(evidence_kind(entry)),
label: Some(entry.label.clone()),
summary: Some(entry.summary.clone()),
@@ -432,6 +501,15 @@ mod tests {
assert!(input.contains("StageMemoryCandidate.entry_refs"));
}
#[test]
fn human_origin_projects_account_authority_into_evidence() {
let origin = evidence_origin(&crate::WorkerHistoryProvenance::HumanInput {
account_id: "account-1".into(),
});
assert_eq!(origin.kind, EvidenceOriginKind::HumanInput);
assert_eq!(origin.account_id.as_deref(), Some("account-1"));
}
#[test]
fn backend_input_failures_remain_invalid_argument_tool_errors() {
let backend = map_memory_stage_error(WorkspaceMemoryBackendError::Backend(
@@ -445,6 +523,19 @@ mod tests {
assert!(matches!(http, ToolError::InvalidArgument(_)));
}
#[tokio::test]
async fn preference_rejects_legacy_unknown_before_backend_mutation() {
let tool = StageMemoryCandidateTool { state: state() };
let error = tool
.execute(
r#"{"kind":"preference","claim":"claim","why_useful":"useful","entry_refs":["E00000000"]}"#,
agen::tool::ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(format!("{error:?}").contains("exclusively HumanInput evidence"));
}
#[tokio::test]
async fn stage_rejects_entry_ref_outside_capture_before_backend_mutation() {
let tool = StageMemoryCandidateTool { state: state() };
@@ -50,11 +50,11 @@ struct MergeRequestTool {
kind: Kind,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ShowInput {
struct TicketInput {
ticket: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct OpenInput {
struct OpenMergeRequestInput {
ticket: String,
repository_id: String,
selector_from: String,
@@ -63,7 +63,7 @@ struct OpenInput {
summary: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct CompleteInput {
struct CompleteMergeRequestInput {
ticket: String,
operation_id: String,
approval_event_id: String,
@@ -86,7 +86,7 @@ enum MergeResolutionInput {
ConflictsResolved,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ReviewInput {
struct ReviewMergeRequestInput {
decision: ReviewDecisionInput,
#[serde(default)]
body: String,
@@ -133,19 +133,19 @@ impl Kind {
fn name(self) -> &'static str {
match self {
Self::Show => "MergeRequestShow",
Self::Readiness => "MergeRequestReadinessCheck",
Self::Open => "MergeRequestOpen",
Self::Complete => "MergeRequestComplete",
Self::Review => "MergeRequestReview",
Self::Show => "ShowMergeRequest",
Self::Readiness => "CheckMergeRequestReadiness",
Self::Open => "OpenMergeRequest",
Self::Complete => "CompleteMergeRequest",
Self::Review => "ReviewMergeRequest",
}
}
fn schema(self) -> serde_json::Value {
match self {
Self::Show | Self::Readiness => json!(schemars::schema_for!(ShowInput)),
Self::Open => json!(schemars::schema_for!(OpenInput)),
Self::Complete => json!(schemars::schema_for!(CompleteInput)),
Self::Review => json!(schemars::schema_for!(ReviewInput)),
Self::Show | Self::Readiness => json!(schemars::schema_for!(TicketInput)),
Self::Open => json!(schemars::schema_for!(OpenMergeRequestInput)),
Self::Complete => json!(schemars::schema_for!(CompleteMergeRequestInput)),
Self::Review => json!(schemars::schema_for!(ReviewMergeRequestInput)),
}
}
}
@@ -156,13 +156,13 @@ impl Tool for MergeRequestTool {
ToolError::ExecutionFailed("Merge Request tools require Workspace identity".into())
})?;
if matches!(self.kind, Kind::Show) {
let value: ShowInput = parse(input)?;
let value: TicketInput = parse(input)?;
nonempty(&value.ticket)?;
return self.show_current_merge_request(ws, &value.ticket);
}
let (method, path, body) = match self.kind {
Kind::Readiness => {
let v: ShowInput = parse(input)?;
let v: TicketInput = parse(input)?;
nonempty(&v.ticket)?;
(
WorkspaceRequestMethod::Get,
@@ -170,9 +170,9 @@ impl Tool for MergeRequestTool {
None,
)
}
Kind::Show => unreachable!("MergeRequestShow is handled above"),
Kind::Show => unreachable!("ShowMergeRequest is handled above"),
Kind::Open => {
let v: OpenInput = parse(input)?;
let v: OpenMergeRequestInput = parse(input)?;
nonempty(&v.ticket)?;
(
WorkspaceRequestMethod::Post,
@@ -183,7 +183,7 @@ impl Tool for MergeRequestTool {
)
}
Kind::Complete => {
let v: CompleteInput = parse(input)?;
let v: CompleteMergeRequestInput = parse(input)?;
nonempty(&v.ticket)?;
(
WorkspaceRequestMethod::Post,
@@ -194,7 +194,7 @@ impl Tool for MergeRequestTool {
)
}
Kind::Review => {
let v: ReviewInput = parse(input)?;
let v: ReviewMergeRequestInput = parse(input)?;
let ctx = self.client.reviewer_context().ok_or_else(|| {
ToolError::ExecutionFailed(
"Review submit requires injected Reviewer capability".into(),
@@ -397,19 +397,21 @@ impl FeatureModule for MergeRequestFeature {
pub fn description(n: &str) -> Option<&'static str> {
match n {
"MergeRequestShow" => Some("Read the selector-based Merge Request and append-only thread."),
"MergeRequestReadinessCheck" => {
Some("Resolve current provider refs and derive readiness from valid review events.")
}
"MergeRequestOpen" => {
Some("Open a Merge Request with immutable source and target selectors.")
}
"MergeRequestComplete" => {
Some("Complete using an approved review event and final target-ref evidence.")
}
"MergeRequestReview" => {
Some("Submit the injected Reviewer capability result for its captured subject ref.")
}
"ShowMergeRequest" => Some(
"Read the selector-based Merge Request, append-only thread, source-review freshness, and target-integration evidence before review, fix, or handoff decisions.",
),
"CheckMergeRequestReadiness" => Some(
"Resolve current provider refs and derive readiness from exact-source review evidence; source movement requires fresh review while target-only movement preserves unchanged-source approval.",
),
"OpenMergeRequest" => Some(
"Open the Ticket's one Merge Request with immutable source and target selectors; reuse it and advance only selector_from with a normal non-force push for later fixes.",
),
"CompleteMergeRequest" => Some(
"Record Orchestrator-owned integration using unchanged-source approval and refreshed final target-ref evidence.",
),
"ReviewMergeRequest" => Some(
"Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.",
),
_ => None,
}
}
@@ -469,6 +471,31 @@ mod tests {
}
}
#[test]
fn model_facing_operations_use_only_verb_first_names() {
for name in [
"ShowMergeRequest",
"CheckMergeRequestReadiness",
"OpenMergeRequest",
"CompleteMergeRequest",
"ReviewMergeRequest",
] {
assert!(description(name).is_some(), "missing operation {name}");
}
for legacy in [
"MergeRequestShow",
"MergeRequestReadinessCheck",
"MergeRequestOpen",
"MergeRequestComplete",
"MergeRequestReview",
] {
assert!(
description(legacy).is_none(),
"legacy alias {legacy} must not remain registered"
);
}
}
#[tokio::test]
async fn show_resolves_ticket_projection_then_reads_canonical_resource() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![
@@ -546,7 +573,7 @@ mod tests {
open: true,
..Default::default()
};
assert_eq!(tool_names(coder), ["MergeRequestShow", "MergeRequestOpen"]);
assert_eq!(tool_names(coder), ["ShowMergeRequest", "OpenMergeRequest"]);
let reviewer = MergeRequestFeatureConfig {
show: true,
@@ -555,7 +582,7 @@ mod tests {
};
assert_eq!(
tool_names(reviewer),
["MergeRequestShow", "MergeRequestReview"]
["ShowMergeRequest", "ReviewMergeRequest"]
);
let orchestrator = MergeRequestFeatureConfig {
@@ -567,9 +594,9 @@ mod tests {
assert_eq!(
tool_names(orchestrator),
[
"MergeRequestShow",
"MergeRequestReadinessCheck",
"MergeRequestComplete"
"ShowMergeRequest",
"CheckMergeRequestReadiness",
"CompleteMergeRequest"
]
);
assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]);
@@ -581,8 +608,8 @@ mod tests {
#[test]
fn schemas_hide_revision_and_commit_authority() {
let schemas = [
schemars::schema_for!(OpenInput),
schemars::schema_for!(CompleteInput),
schemars::schema_for!(OpenMergeRequestInput),
schemars::schema_for!(CompleteMergeRequestInput),
];
for s in schemas {
let j = serde_json::to_string(&s).unwrap();
+208 -25
View File
@@ -14,6 +14,8 @@ use serde_json::json;
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use super::resource_projection::{project_objective_detail, project_objective_query};
#[derive(Clone, Debug)]
pub struct WorkspaceHttpObjectiveBackend {
client: Arc<dyn WorkspaceClient>,
@@ -37,6 +39,7 @@ impl WorkspaceHttpObjectiveBackend {
)
.await
.map_err(backend_error)?;
let response = project_objective_query(response).map_err(ToolError::ExecutionFailed)?;
Ok(ToolOutput {
summary: "Queried Objectives".to_string(),
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
@@ -58,8 +61,10 @@ impl WorkspaceHttpObjectiveBackend {
)
.await
.map_err(backend_error)?;
let response = project_objective_detail(response).map_err(ToolError::ExecutionFailed)?;
let objective_ref = response.objective_ref().to_string();
Ok(ToolOutput {
summary: format!("Read objective {id}"),
summary: format!("Read objective {objective_ref}"),
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
attachments: Vec::new(),
})
@@ -84,7 +89,7 @@ impl WorkspaceHttpObjectiveBackend {
.await
.map_err(backend_error)?;
Ok(objective_output(
format!("Created objective {}", response.id),
format!("Created objective {}", &response.resource_key),
response,
)?)
}
@@ -112,7 +117,7 @@ impl WorkspaceHttpObjectiveBackend {
.await
.map_err(backend_error)?;
Ok(objective_output(
format!("Edited objective {}", response.id),
format!("Edited objective {}", &response.resource_key),
response,
)?)
}
@@ -134,7 +139,7 @@ impl WorkspaceHttpObjectiveBackend {
.await
.map_err(backend_error)?;
Ok(objective_output(
format!("Updated objective {} state", response.id),
format!("Updated objective {} state", &response.resource_key),
response,
)?)
}
@@ -142,6 +147,7 @@ impl WorkspaceHttpObjectiveBackend {
async fn link_ticket(&self, input: ObjectiveLinkTicketInput) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ObjectiveLinkTicket")?;
let ticket_id = validate_id(&input.ticket_id, "ObjectiveLinkTicket")?;
let ticket_resource_key = self.ticket_resource_key(ticket_id).await?;
let url = format!("{}/ticket-links", self.objective_url(id));
let response = send_json::<ObjectiveLinkTicketRequest, ObjectiveDetail>(
self.client.as_ref(),
@@ -154,7 +160,10 @@ impl WorkspaceHttpObjectiveBackend {
.await
.map_err(backend_error)?;
Ok(objective_output(
format!("Linked ticket {ticket_id} to objective {}", response.id),
format!(
"Linked ticket {ticket_resource_key} to objective {}",
&response.resource_key
),
response,
)?)
}
@@ -165,16 +174,44 @@ impl WorkspaceHttpObjectiveBackend {
) -> Result<ToolOutput, ToolError> {
let id = validate_id(&input.id, "ObjectiveUnlinkTicket")?;
let ticket_id = validate_id(&input.ticket_id, "ObjectiveUnlinkTicket")?;
let ticket_resource_key = self.ticket_resource_key(ticket_id).await?;
let url = format!("{}/ticket-links/{}", self.objective_url(id), ticket_id);
let response = delete_json::<ObjectiveDetail>(self.client.as_ref(), &url)
.await
.map_err(backend_error)?;
Ok(objective_output(
format!("Unlinked ticket {ticket_id} from objective {}", response.id),
format!(
"Unlinked ticket {ticket_resource_key} from objective {}",
&response.resource_key
),
response,
)?)
}
async fn ticket_resource_key(&self, ticket_reference: &str) -> Result<String, ToolError> {
let workspace_id = self.client.workspace_id().unwrap_or_default();
let response: serde_json::Value = decode_response(
self.client
.execute(WorkspaceRequest::get(format!(
"/api/w/{workspace_id}/tickets/{ticket_reference}"
)))
.map_err(WorkspaceObjectiveBackendError::from)
.map_err(backend_error)?,
)
.map_err(backend_error)?;
response
.get("resource_key")
.or_else(|| {
response
.get("meta")
.and_then(|meta| meta.get("resource_key"))
})
.and_then(serde_json::Value::as_str)
.filter(|key| is_canonical_resource_key(key, "T-"))
.map(ToOwned::to_owned)
.ok_or_else(|| ToolError::ExecutionFailed("required T- key is unavailable".to_string()))
}
fn objective_url(&self, id: &str) -> String {
let workspace_id = self.client.workspace_id().unwrap_or_default();
format!("/api/w/{workspace_id}/objectives/{id}")
@@ -185,7 +222,7 @@ impl WorkspaceHttpObjectiveBackend {
pub enum WorkspaceObjectiveBackendError {
#[error("workspace objective backend request failed: {0}")]
Request(#[from] crate::worker::WorkspaceClientError),
#[error("workspace objective backend returned HTTP {status}: {body}")]
#[error("workspace objective backend returned HTTP {status}")]
Http {
status: reqwest::StatusCode,
body: String,
@@ -247,10 +284,26 @@ fn decode_response<T: for<'de> Deserialize<'de>>(
serde_json::from_str(&response.body).map_err(Into::into)
}
fn is_canonical_resource_key(resource_key: &str, prefix: &str) -> bool {
resource_key.strip_prefix(prefix).is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn objective_output(summary: String, response: ObjectiveDetail) -> Result<ToolOutput, ToolError> {
if !is_canonical_resource_key(&response.resource_key, "O-") {
return Err(ToolError::ExecutionFailed(
"required O- key is unavailable".to_string(),
));
}
let projected = serde_json::json!({
"objective": &response.resource_key,
"title": response.title,
"state": response.state,
});
Ok(ToolOutput {
summary,
content: Some(serde_json::to_string_pretty(&response).map_err(decode_error)?),
content: Some(serde_json::to_string_pretty(&projected).map_err(decode_error)?),
attachments: Vec::new(),
})
@@ -260,7 +313,7 @@ fn validate_id<'a>(id: &'a str, tool_name: &str) -> Result<&'a str, ToolError> {
let id = id.trim();
if id.is_empty() || id.contains('/') {
return Err(ToolError::InvalidArgument(format!(
"{tool_name} requires non-empty canonical id without '/'"
"{tool_name} requires a non-empty Objective reference without '/'"
)));
}
Ok(id)
@@ -411,9 +464,9 @@ const EDIT_DESCRIPTION: &str =
const SET_STATE_DESCRIPTION: &str =
"Set an Objective state through Backend Workspace API authority.";
const LINK_TICKET_DESCRIPTION: &str =
"Link a Ticket id to an Objective through Backend Workspace API authority.";
"Link a Ticket reference to an Objective through Backend Workspace API authority.";
const UNLINK_TICKET_DESCRIPTION: &str =
"Unlink a Ticket id from an Objective through Backend Workspace API authority.";
"Unlink a Ticket reference from an Objective through Backend Workspace API authority.";
fn list_schema() -> serde_json::Value {
json!({
@@ -422,7 +475,7 @@ fn list_schema() -> serde_json::Value {
"properties":{
"query":{"type":["string","null"]},
"states":{"type":"array","items":{"type":"string"},"default":[]},
"linked_ticket_id":{"type":["string","null"]},
"linked_ticket_id":{"type":["string","null"],"description":"Linked Ticket reference. Prefer T-*; canonical internal ids remain accepted for compatibility."},
"updated_after":{"type":["string","null"]},
"updated_before":{"type":["string","null"]},
"sort":{"type":["string","null"],"enum":["relevance","updated_desc","created_desc","title",null]},
@@ -438,7 +491,7 @@ fn show_schema() -> serde_json::Value {
"additionalProperties": false,
"required":["id"],
"properties":{
"id":{"type":"string"},
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
"event_limit":{"type":["integer","null"],"minimum":1,"maximum":50},
"event_cursor":{"type":["string","null"]}
}
@@ -454,7 +507,7 @@ fn create_schema() -> serde_json::Value {
"title":{"type":"string","minLength":1},
"body_md":{"type":"string"},
"state":{"type":"string","default":"active"},
"linked_tickets":{"type":"array","items":{"type":"string"}}
"linked_tickets":{"type":"array","items":{"type":"string"},"description":"Linked Ticket references. Prefer T-*; canonical internal ids remain accepted for compatibility."}
}
})
}
@@ -465,7 +518,7 @@ fn edit_schema() -> serde_json::Value {
"additionalProperties": false,
"required":["id"],
"properties":{
"id":{"type":"string"},
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
"title":{"type":["string","null"]},
"old_string":{"type":["string","null"]},
"new_string":{"type":["string","null"]},
@@ -480,7 +533,7 @@ fn set_state_schema() -> serde_json::Value {
"additionalProperties": false,
"required":["id","state"],
"properties":{
"id":{"type":"string"},
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
"state":{"type":"string","minLength":1}
}
})
@@ -500,8 +553,8 @@ fn id_ticket_schema(required: &[&str]) -> serde_json::Value {
"additionalProperties": false,
"required": required,
"properties":{
"id":{"type":"string"},
"ticket_id":{"type":"string"}
"id":{"type":"string","description":"Objective reference. Prefer O-*; canonical internal ids remain accepted for compatibility."},
"ticket_id":{"type":"string","description":"Ticket reference. Prefer T-*; canonical internal ids remain accepted for compatibility."}
}
})
}
@@ -595,21 +648,20 @@ fn default_state() -> String {
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
struct ObjectiveDetail {
id: String,
resource_key: String,
title: String,
state: String,
created_at: Option<String>,
updated_at: Option<String>,
linked_tickets: Vec<String>,
body: String,
body_truncated: bool,
record_source: String,
}
#[cfg(test)]
mod tests {
use super::*;
use agen::tool::ToolDefinition;
use std::{
io::{Read, Write},
net::TcpListener,
thread,
};
fn tool_names(definitions: Vec<ToolDefinition>) -> Vec<String> {
let mut names = definitions
@@ -656,4 +708,135 @@ mod tests {
let link = link_ticket_schema();
assert_eq!(link["required"], json!(["id", "ticket_id"]));
}
#[tokio::test(flavor = "multi_thread")]
async fn objective_show_summary_uses_projected_key() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(
request.starts_with("POST /api/w/workspace/objectives/00001INTERNAL/show HTTP/1.1")
);
let body = serde_json::json!({
"id": "00001INTERNAL",
"resource_key": "O-3",
"title": "Objective",
"body": "Body",
"state": "active",
"created_at": null,
"updated_at": null,
"linked_ticket_summaries": [],
"events": [],
"event_page": {"next_cursor": null, "has_more": false}
})
.to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
)
.unwrap();
});
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
));
let output = backend
.show(ShowObjectiveInput {
id: "00001INTERNAL".to_string(),
event_limit: None,
event_cursor: None,
})
.await
.unwrap();
server.join().unwrap();
assert_eq!(output.summary, "Read objective O-3");
assert!(!output.summary.contains("00001INTERNAL"));
assert!(!output.content.unwrap().contains("00001INTERNAL"));
}
#[tokio::test(flavor = "multi_thread")]
async fn objective_link_summaries_resolve_internal_ticket_ids_to_keys() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
for mutation in ["POST", "DELETE"] {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with("GET /api/w/workspace/tickets/00001INTERNAL HTTP/1.1"));
let response_body = serde_json::json!({"resource_key": "T-7"}).to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with(&format!(
"{mutation} /api/w/workspace/objectives/O-3/ticket-links"
)));
let response_body = serde_json::json!({
"resource_key": "O-3",
"title": "Objective",
"state": "active"
})
.to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
}
});
let backend = WorkspaceHttpObjectiveBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", base_url),
));
let linked = backend
.link_ticket(ObjectiveLinkTicketInput {
id: "O-3".to_string(),
ticket_id: "00001INTERNAL".to_string(),
})
.await
.unwrap();
let unlinked = backend
.unlink_ticket(ObjectiveUnlinkTicketInput {
id: "O-3".to_string(),
ticket_id: "00001INTERNAL".to_string(),
})
.await
.unwrap();
server.join().unwrap();
for output in [linked, unlinked] {
assert!(output.summary.contains("T-7"));
assert!(!output.summary.contains("00001INTERNAL"));
assert!(!output.content.unwrap().contains("00001INTERNAL"));
}
}
#[test]
fn objective_output_rejects_noncanonical_keys() {
let response = ObjectiveDetail {
resource_key: "O-internal".to_string(),
title: "Objective".to_string(),
state: "active".to_string(),
};
assert!(objective_output("created".to_string(), response).is_err());
}
}
@@ -89,18 +89,19 @@ impl Tool for SpawnTicketCoderTool {
let input: SpawnTicketCoderInput = serde_json::from_str(input_json).map_err(|error| {
ToolError::InvalidArgument(format!("invalid {TOOL_NAME} input: {error}"))
})?;
let ticket_id = authority_id(input.ticket_id, "ticket_id")?;
let workflow_state = self
let ticket_ref = authority_id(input.ticket_id, "ticket_id")?;
let ticket = self
.ticket_service
.workflow_state(&ticket_id)
.ticket_handoff(&ticket_ref)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !matches!(
workflow_state,
ticket.workflow_state,
ticket::TicketWorkflowState::Queued | ticket::TicketWorkflowState::InProgress
) {
return Err(ToolError::ExecutionFailed(format!(
"Ticket {ticket_id} must be queued or inprogress before spawning its Coder; current state is {}",
workflow_state.as_str()
"Ticket {} must be queued or inprogress before spawning its Coder; current state is {}",
ticket.resource_key,
ticket.workflow_state.as_str()
)));
}
let call_id = non_empty(ctx.call_id, "tool call_id")?;
@@ -115,14 +116,14 @@ impl Tool for SpawnTicketCoderTool {
)?,
relative_cwd,
profile: CODER_PROFILE.to_string(),
ticket_id: Some(ticket_id.clone()),
operation_id: Some(format!("spawn-ticket-coder:{ticket_id}:{call_id}")),
display_name: format!("Coder · {ticket_id}"),
ticket_id: Some(ticket.id.clone()),
operation_id: Some(format!("spawn-ticket-coder:{}:{call_id}", ticket.id)),
display_name: format!("Coder · {}", ticket.resource_key),
initial_submit: vec![
Segment::Flow {
selector: CODER_FLOW.to_string(),
},
Segment::text(format!("Implement Ticket {ticket_id}.")),
Segment::text(format!("Implement Ticket {}.", ticket.resource_key)),
],
})
.await
@@ -134,7 +135,7 @@ impl Tool for SpawnTicketCoderTool {
)));
}
Ok(ToolOutput {
summary: format!("Spawned Coder for Ticket {ticket_id}"),
summary: format!("Spawned Coder for Ticket {}", ticket.resource_key),
content: Some(response.body),
attachments: Vec::new(),
})
@@ -201,21 +202,31 @@ mod tests {
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
use super::*;
use crate::feature::builtin::ticket::TicketHandoff;
#[derive(Default)]
struct RecordingTicketService;
impl TicketService for RecordingTicketService {
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
Ok(TicketWorkflowState::Queued)
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
assert_eq!(ticket_ref, "T-482");
Ok(TicketHandoff {
id: "00001KZXN51C7".to_string(),
resource_key: "T-482".to_string(),
workflow_state: TicketWorkflowState::Queued,
})
}
}
struct FixedTicketService(TicketWorkflowState);
impl TicketService for FixedTicketService {
fn workflow_state(&self, _ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
Ok(self.0)
fn ticket_handoff(&self, _ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
Ok(TicketHandoff {
id: "00001KZXN51C7".to_string(),
resource_key: "T-482".to_string(),
workflow_state: self.0,
})
}
}
@@ -247,7 +258,7 @@ mod tests {
};
tool.execute(
&serde_json::json!({
"ticket_id": "00001KZXN51C7",
"ticket_id": "T-482",
"runtime_id": "runtime-1",
"working_directory_id": "workdir-1"
})
@@ -265,16 +276,20 @@ mod tests {
request.operation_id.as_deref(),
Some("spawn-ticket-coder:00001KZXN51C7:call-7")
);
assert_eq!(request.display_name, "Coder · 00001KZXN51C7");
assert_eq!(request.display_name, "Coder · T-482");
assert_eq!(
request.initial_submit,
vec![
Segment::Flow {
selector: CODER_FLOW.to_string()
},
Segment::text("Implement Ticket 00001KZXN51C7.")
Segment::text("Implement Ticket T-482.")
]
);
assert!(!request.display_name.contains("00001KZXN51C7"));
assert!(request.initial_submit.iter().all(|segment| {
!Segment::flatten_to_text(std::slice::from_ref(segment)).contains("00001KZXN51C7")
}));
}
#[tokio::test]
@@ -0,0 +1,792 @@
use serde::Serialize;
use serde_json::{Map, Value};
#[derive(Debug, Serialize)]
pub(super) struct ModelTicketQueryResponse {
tickets: Vec<ModelTicketQueryItem>,
next_cursor: Option<String>,
has_more: bool,
}
#[derive(Debug, Serialize)]
struct ModelTicketQueryItem {
ticket: String,
title: String,
state: String,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
workspace_action_priority: Option<String>,
matched_fields: Vec<String>,
snippet: Option<String>,
current_coder: Option<ModelWorkerSummary>,
linked_objectives: Vec<String>,
relation_count: usize,
blocker_count: usize,
unresolved_blocker_count: usize,
unresolved_review_count: usize,
evidence: Option<ModelTicketEvidence>,
merge_request: Option<ModelMergeRequest>,
}
#[derive(Debug, Serialize)]
pub(super) struct ModelTicketDetail {
ticket: String,
title: String,
body: String,
state: String,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
thread: Vec<ModelTicketEvent>,
relations: ModelTicketRelations,
linked_objectives: Vec<ModelObjectiveSummary>,
assignments: Vec<ModelAssignment>,
current_coder: Option<ModelWorkerSummary>,
implementation_reports: Vec<ModelEvidenceEvent>,
merge_request: Option<ModelMergeRequest>,
evidence: Option<ModelTicketEvidence>,
actions: Option<ModelTicketActions>,
event_page: Option<ModelEventPage>,
}
#[derive(Debug, Serialize)]
pub(super) struct ModelObjectiveQueryResponse {
objectives: Vec<ModelObjectiveQueryItem>,
next_cursor: Option<String>,
has_more: bool,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveQueryItem {
objective: String,
title: String,
summary: Option<String>,
state: String,
created_at: Option<String>,
updated_at: Option<String>,
linked_tickets: Vec<String>,
linked_ticket_count: usize,
}
#[derive(Debug, Serialize)]
pub(super) struct ModelObjectiveDetail {
objective: String,
title: String,
body: String,
state: String,
created_at: Option<String>,
updated_at: Option<String>,
linked_tickets: Vec<ModelTicketSummary>,
events: Vec<ModelObjectiveEvent>,
event_page: ModelObjectiveEventPage,
}
impl ModelObjectiveDetail {
pub(super) fn objective_ref(&self) -> &str {
&self.objective
}
}
#[derive(Debug, Serialize)]
struct ModelWorkerSummary {
worker: String,
}
#[derive(Debug, Serialize)]
struct ModelTicketEvent {
sequence: usize,
kind: String,
body: Option<String>,
created_at: Option<String>,
}
#[derive(Debug, Serialize, Default)]
struct ModelTicketRelations {
outgoing: Vec<ModelRelation>,
incoming: Vec<ModelRelation>,
blockers: Vec<ModelBlocker>,
notices: Vec<ModelNotice>,
}
#[derive(Debug, Serialize)]
struct ModelRelation {
ticket: String,
kind: String,
note: Option<String>,
created_at: Option<String>,
}
#[derive(Debug, Serialize)]
struct ModelBlocker {
ticket: String,
kind: String,
state: Option<String>,
resolved: bool,
}
#[derive(Debug, Serialize)]
struct ModelNotice {
kind: String,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveSummary {
objective: String,
title: String,
state: String,
}
#[derive(Debug, Serialize)]
struct ModelTicketSummary {
ticket: String,
title: String,
state: String,
}
#[derive(Debug, Serialize)]
struct ModelAssignment {
role: String,
principal: String,
assigned_at: String,
}
#[derive(Debug, Serialize)]
struct ModelEvidenceEvent {
sequence: usize,
kind: String,
created_at: Option<String>,
excerpt: String,
}
#[derive(Debug, Serialize)]
struct ModelMergeRequest {
state: String,
selector_from: Option<String>,
selector_to: String,
review_status: String,
subject_ref: Option<String>,
review_excerpt: Option<String>,
}
#[derive(Debug, Serialize)]
struct ModelTicketEvidence {
has_merge_request: bool,
has_current_subject_ref: bool,
has_review_request: bool,
has_commit: bool,
review_status: Option<String>,
approved_current_subject: bool,
unresolved_request_changes: bool,
complete_for_integration: bool,
missing: Vec<String>,
}
#[derive(Debug, Serialize)]
struct ModelTicketActions {
can_assign_orchestrator: bool,
can_unassign_orchestrator: bool,
can_queue: bool,
can_start_manual_coder: bool,
}
#[derive(Debug, Serialize)]
struct ModelEventPage {
next_cursor: Option<String>,
has_more: bool,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveEvent {
kind: String,
created_at: String,
body: Option<String>,
}
#[derive(Debug, Serialize)]
struct ModelObjectiveEventPage {
next_cursor: Option<String>,
has_more: bool,
}
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
let root = object(&value, "Ticket query response")?;
let page = object_field(root, "page")?;
let tickets = array_field(root, "items")?
.iter()
.map(project_ticket_query_item)
.collect::<Result<Vec<_>, _>>()?;
Ok(ModelTicketQueryResponse {
tickets,
next_cursor: optional_string(page, "next_cursor")?,
has_more: bool_field(page, "has_more")?,
})
}
fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, String> {
let item = object(value, "Ticket query item")?;
Ok(ModelTicketQueryItem {
ticket: resource_ref(item, "resource_key", "T-")?,
title: string_field(item, "title")?,
state: string_field(item, "state")?,
readiness: optional_string(item, "readiness")?,
priority: optional_string(item, "priority")?,
created_at: optional_string(item, "created_at")?,
updated_at: optional_string(item, "updated_at")?,
workspace_action_priority: optional_string(item, "workspace_action_priority")?,
matched_fields: string_array(item, "matched_fields")?,
snippet: optional_string(item, "snippet")?,
current_coder: item
.get("current_coder")
.filter(|value| !value.is_null())
.map(project_worker)
.transpose()?,
linked_objectives: string_array(item, "linked_objective_keys")?
.into_iter()
.map(|key| validate_resource_ref(key, "O-"))
.collect::<Result<Vec<_>, _>>()?,
relation_count: usize_field(item, "relation_count")?,
blocker_count: usize_field(item, "blocker_count")?,
unresolved_blocker_count: usize_field(item, "unresolved_blocker_count")?,
unresolved_review_count: usize_field(item, "unresolved_review_count")?,
evidence: item.get("evidence").map(project_evidence).transpose()?,
merge_request: item
.get("merge_request")
.filter(|value| !value.is_null())
.map(project_merge_request)
.transpose()?,
})
}
pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, String> {
let root = object(&value, "Ticket detail response")?;
let current_coder = root
.get("current_coder")
.filter(|value| !value.is_null())
.map(project_worker)
.transpose()?;
let assignments = array_field(root, "assignments")?
.iter()
.map(|assignment| project_assignment(assignment, current_coder.as_ref()))
.collect::<Result<Vec<_>, _>>()?;
Ok(ModelTicketDetail {
ticket: resource_ref(root, "resource_key", "T-")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
readiness: optional_string(root, "readiness")?,
priority: optional_string(root, "priority")?,
created_at: optional_string(root, "created_at")?,
updated_at: optional_string(root, "updated_at")?,
thread: array_field(root, "events")?
.iter()
.map(project_ticket_event)
.collect::<Result<Vec<_>, _>>()?,
relations: project_relations(root.get("relations"))?,
linked_objectives: array_field(root, "linked_objectives")?
.iter()
.map(project_objective_summary)
.collect::<Result<Vec<_>, _>>()?,
assignments,
current_coder,
implementation_reports: array_field(root, "implementation_reports")?
.iter()
.map(project_evidence_event)
.collect::<Result<Vec<_>, _>>()?,
merge_request: root
.get("merge_request")
.filter(|value| !value.is_null())
.map(project_merge_request)
.transpose()?,
evidence: root.get("evidence").map(project_evidence).transpose()?,
actions: root
.get("action_eligibility")
.filter(|value| !value.is_null())
.map(project_actions)
.transpose()?,
event_page: root
.get("event_page")
.filter(|value| !value.is_null())
.map(project_event_page)
.transpose()?,
})
}
pub(super) fn project_objective_query(value: Value) -> Result<ModelObjectiveQueryResponse, String> {
let root = object(&value, "Objective query response")?;
let page = object_field(root, "page")?;
Ok(ModelObjectiveQueryResponse {
objectives: array_field(root, "items")?
.iter()
.map(project_objective_query_item)
.collect::<Result<Vec<_>, _>>()?,
next_cursor: optional_string(page, "next_cursor")?,
has_more: bool_field(page, "has_more")?,
})
}
fn project_objective_query_item(value: &Value) -> Result<ModelObjectiveQueryItem, String> {
let item = object(value, "Objective query item")?;
let linked_tickets = string_array(item, "linked_ticket_keys")?
.into_iter()
.map(|key| validate_resource_ref(key, "T-"))
.collect::<Result<Vec<_>, _>>()?;
Ok(ModelObjectiveQueryItem {
objective: resource_ref(item, "resource_key", "O-")?,
title: string_field(item, "title")?,
summary: optional_string(item, "snippet")?,
state: string_field(item, "state")?,
created_at: optional_string(item, "created_at")?,
updated_at: optional_string(item, "updated_at")?,
linked_ticket_count: linked_tickets.len(),
linked_tickets,
})
}
pub(super) fn project_objective_detail(value: Value) -> Result<ModelObjectiveDetail, String> {
let root = object(&value, "Objective detail response")?;
Ok(ModelObjectiveDetail {
objective: resource_ref(root, "resource_key", "O-")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
created_at: optional_string(root, "created_at")?,
updated_at: optional_string(root, "updated_at")?,
linked_tickets: array_field(root, "linked_ticket_summaries")?
.iter()
.map(project_ticket_summary)
.collect::<Result<Vec<_>, _>>()?,
events: array_field(root, "events")?
.iter()
.map(project_objective_event)
.collect::<Result<Vec<_>, _>>()?,
event_page: project_objective_event_page(
root.get("event_page")
.ok_or_else(|| "Objective detail response is missing event_page".to_string())?,
)?,
})
}
fn project_worker(value: &Value) -> Result<ModelWorkerSummary, String> {
let worker = object(value, "Worker summary")?;
Ok(ModelWorkerSummary {
worker: resource_ref(worker, "worker_resource_key", "W-")?,
})
}
fn project_ticket_event(value: &Value) -> Result<ModelTicketEvent, String> {
let event = object(value, "Ticket event")?;
Ok(ModelTicketEvent {
sequence: usize_field(event, "sequence")?,
kind: string_field(event, "kind")?,
body: match event.get("body") {
None | Some(Value::Null) => None,
Some(Value::String(body)) => Some(body.clone()),
Some(_) => return Err("invalid Ticket event body".to_string()),
},
created_at: optional_string(event, "at")?,
})
}
fn project_relations(value: Option<&Value>) -> Result<ModelTicketRelations, String> {
let Some(value) = value else {
return Ok(ModelTicketRelations::default());
};
let relations = object(value, "Ticket relations")?;
Ok(ModelTicketRelations {
outgoing: array_field(relations, "outgoing")?
.iter()
.map(|value| project_relation(value, "target_resource_key", "kind"))
.collect::<Result<Vec<_>, _>>()?,
incoming: array_field(relations, "incoming")?
.iter()
.map(|value| project_relation(value, "source_resource_key", "forward_kind"))
.collect::<Result<Vec<_>, _>>()?,
blockers: array_field(relations, "blockers")?
.iter()
.map(project_blocker)
.collect::<Result<Vec<_>, _>>()?,
notices: array_field(relations, "notices")?
.iter()
.map(project_notice)
.collect::<Result<Vec<_>, _>>()?,
})
}
fn project_relation(
value: &Value,
ticket_key: &str,
kind_key: &str,
) -> Result<ModelRelation, String> {
let relation = object(value, "Ticket relation")?;
let relation_data = relation.get("relation").and_then(Value::as_object);
let kind = if kind_key == "kind" {
relation_data
.ok_or_else(|| "Ticket relation is missing relation data".to_string())
.and_then(|data| string_field(data, "kind"))?
} else {
string_field(relation, kind_key)?
};
let note = match relation_data {
Some(data) => optional_string(data, "note")?,
None => optional_string(relation, "note")?,
};
let created_at = match relation_data {
Some(data) => optional_string(data, "at")?,
None => optional_string(relation, "at")?,
};
Ok(ModelRelation {
ticket: resource_ref(relation, ticket_key, "T-")?,
kind,
note,
created_at,
})
}
fn project_blocker(value: &Value) -> Result<ModelBlocker, String> {
let blocker = object(value, "Ticket blocker")?;
Ok(ModelBlocker {
ticket: resource_ref(blocker, "blocking_resource_key", "T-")?,
kind: string_field(blocker, "relation_kind")?,
state: optional_string(blocker, "blocking_state")?,
resolved: bool_field(blocker, "resolved")?,
})
}
fn project_notice(value: &Value) -> Result<ModelNotice, String> {
let notice = object(value, "Ticket notice")?;
Ok(ModelNotice {
kind: string_field(notice, "kind")?,
})
}
fn project_objective_summary(value: &Value) -> Result<ModelObjectiveSummary, String> {
let summary = object(value, "Objective summary")?;
Ok(ModelObjectiveSummary {
objective: resource_ref(summary, "resource_key", "O-")?,
title: string_field(summary, "title")?,
state: string_field(summary, "state")?,
})
}
fn project_ticket_summary(value: &Value) -> Result<ModelTicketSummary, String> {
let summary = object(value, "Ticket summary")?;
Ok(ModelTicketSummary {
ticket: resource_ref(summary, "resource_key", "T-")?,
title: string_field(summary, "title")?,
state: string_field(summary, "state")?,
})
}
fn project_assignment(
value: &Value,
current_coder: Option<&ModelWorkerSummary>,
) -> Result<ModelAssignment, String> {
let assignment = object(value, "Ticket assignment")?;
let principal = object_field(assignment, "principal")?;
let kind = string_field(principal, "kind")?;
let principal = match kind.as_str() {
"worker" => current_coder
.map(|coder| coder.worker.clone())
.ok_or_else(|| "Worker assignment is missing a Workspace key projection".to_string())?,
"workspace_agent" => format!("workspace-agent:{}", string_field(principal, "agent_key")?),
"user" => "user".to_string(),
other => format!("source:{other}"),
};
Ok(ModelAssignment {
role: string_field(assignment, "role")?,
principal,
assigned_at: string_field(assignment, "assigned_at")?,
})
}
fn project_evidence_event(value: &Value) -> Result<ModelEvidenceEvent, String> {
let event = object(value, "Ticket evidence event")?;
Ok(ModelEvidenceEvent {
sequence: usize_field(event, "sequence")?,
kind: string_field(event, "kind")?,
created_at: optional_string(event, "at")?,
excerpt: string_field(event, "excerpt")?,
})
}
fn project_merge_request(value: &Value) -> Result<ModelMergeRequest, String> {
let merge = object(value, "Merge Request summary")?;
Ok(ModelMergeRequest {
state: string_field(merge, "state")?,
selector_from: optional_string(merge, "selector_from")?,
selector_to: string_field(merge, "selector_to")?,
review_status: string_field(merge, "review_status")?,
subject_ref: optional_string(merge, "subject_ref")?,
review_excerpt: optional_string(merge, "review_excerpt")?,
})
}
fn project_evidence(value: &Value) -> Result<ModelTicketEvidence, String> {
let evidence = object(value, "Ticket evidence")?;
Ok(ModelTicketEvidence {
has_merge_request: bool_field(evidence, "has_merge_request")?,
has_current_subject_ref: bool_field(evidence, "has_current_subject_ref")?,
has_review_request: bool_field(evidence, "has_review_request")?,
has_commit: bool_field(evidence, "has_commit")?,
review_status: optional_string(evidence, "review_status")?,
approved_current_subject: bool_field(evidence, "approved_current_subject")?,
unresolved_request_changes: bool_field(evidence, "unresolved_request_changes")?,
complete_for_integration: bool_field(evidence, "complete_for_integration")?,
missing: string_array(evidence, "missing")?,
})
}
fn project_actions(value: &Value) -> Result<ModelTicketActions, String> {
let actions = object(value, "Ticket actions")?;
Ok(ModelTicketActions {
can_assign_orchestrator: bool_field(actions, "can_assign_orchestrator")?,
can_unassign_orchestrator: bool_field(actions, "can_unassign_orchestrator")?,
can_queue: bool_field(actions, "can_queue")?,
can_start_manual_coder: bool_field(actions, "can_start_manual_coder")?,
})
}
fn project_event_page(value: &Value) -> Result<ModelEventPage, String> {
let page = object(value, "Ticket event page")?;
Ok(ModelEventPage {
next_cursor: optional_string(page, "next_cursor")?,
has_more: bool_field(page, "has_more")?,
})
}
fn project_objective_event(value: &Value) -> Result<ModelObjectiveEvent, String> {
let event = object(value, "Objective event")?;
let body = optional_string(event, "body")?;
Ok(ModelObjectiveEvent {
kind: string_field(event, "kind")?,
created_at: string_field(event, "created_at")?,
body,
})
}
fn project_objective_event_page(value: &Value) -> Result<ModelObjectiveEventPage, String> {
let page = object(value, "Objective event page")?;
Ok(ModelObjectiveEventPage {
next_cursor: optional_string(page, "next_cursor")?,
has_more: bool_field(page, "has_more")?,
})
}
fn object<'a>(value: &'a Value, context: &str) -> Result<&'a Map<String, Value>, String> {
value
.as_object()
.ok_or_else(|| format!("{context} must be an object"))
}
fn object_field<'a>(
object: &'a Map<String, Value>,
key: &str,
) -> Result<&'a Map<String, Value>, String> {
object
.get(key)
.and_then(Value::as_object)
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn array_field<'a>(object: &'a Map<String, Value>, key: &str) -> Result<&'a [Value], String> {
object
.get(key)
.and_then(Value::as_array)
.map(Vec::as_slice)
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn string_field(object: &Map<String, Value>, key: &str) -> Result<String, String> {
object
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn optional_string(object: &Map<String, Value>, key: &str) -> Result<Option<String>, String> {
match object.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::String(value)) => Ok(Some(value.clone())),
Some(_) => Err(format!("invalid {key}")),
}
}
fn bool_field(object: &Map<String, Value>, key: &str) -> Result<bool, String> {
object
.get(key)
.and_then(Value::as_bool)
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn usize_field(object: &Map<String, Value>, key: &str) -> Result<usize, String> {
object
.get(key)
.and_then(Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
.ok_or_else(|| format!("missing or invalid {key}"))
}
fn string_array(object: &Map<String, Value>, key: &str) -> Result<Vec<String>, String> {
array_field(object, key)?
.iter()
.map(|value| {
value
.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| format!("invalid {key}"))
})
.collect()
}
fn resource_ref(object: &Map<String, Value>, key: &str, prefix: &str) -> Result<String, String> {
let value = object
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| format!("required {prefix} key is unavailable"))?;
validate_resource_ref(value, prefix)
}
fn validate_resource_ref(value: String, prefix: &str) -> Result<String, String> {
let valid = value.strip_prefix(prefix).is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
});
if valid {
Ok(value)
} else {
Err(format!("required {prefix} key is unavailable"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn objective_projection_exposes_only_resource_references() {
let projected = project_objective_detail(json!({
"id": "00001M10HW6BV",
"resource_key": "O-543",
"title": "Objective",
"body": "Body",
"state": "active",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-02T00:00:00Z",
"linked_tickets": ["00001M0E82D1V"],
"linked_ticket_summaries": [{
"id": "00001M0E82D1V",
"resource_key": "T-496",
"title": "Ticket",
"state": "done",
"updated_at": "2026-01-02T00:00:00Z"
}],
"events": [{
"sequence": 3,
"event_ref": "objective-event-3",
"kind": "linked_ticket",
"created_at": "2026-01-02T00:00:00Z",
"body": "linked"
}],
"event_page": {"next_cursor": null, "has_more": false, "window_start_sequence": 3, "window_end_sequence": 3}
})).expect("projection");
let json = serde_json::to_value(projected).expect("serialize");
let text = json.to_string();
assert!(text.contains("O-543"));
assert!(text.contains("T-496"));
assert!(!text.contains("00001M10HW6BV"));
assert!(!text.contains("00001M0E82D1V"));
assert!(!text.contains("event_ref"));
}
#[test]
fn query_projections_accept_workspace_api_shapes_and_scrub_internal_ids() {
let ticket = project_ticket_query(json!({
"page": {"next_cursor": null, "has_more": false},
"record_authority": "workspace_sqlite",
"items": [{
"id": "00001TICKETINTERNAL",
"resource_key": "T-543",
"title": "Ticket",
"state": "inprogress",
"readiness": null,
"priority": "high",
"created_at": null,
"updated_at": "2026-01-01T00:00:00Z",
"workspace_action_priority": "active_work",
"matched_fields": ["title"],
"snippet": "Ticket",
"current_coder": {"runtime_id": "runtime-internal", "worker_id": "worker-internal", "worker_resource_key": "W-12"},
"linked_objective_ids": ["00001OBJECTIVEINTERNAL"],
"linked_objective_keys": ["O-6"],
"relation_count": 0,
"blocker_count": 0,
"unresolved_blocker_count": 0,
"unresolved_review_count": 0,
"evidence": {
"has_merge_request": false,
"has_current_subject_ref": false,
"has_review_request": false,
"has_commit": false,
"review_status": null,
"approved_current_subject": false,
"unresolved_request_changes": false,
"complete_for_integration": false,
"missing": ["merge_request"]
},
"merge_request": null
}]
})).expect("Ticket query projection");
let ticket_json = serde_json::to_string(&ticket).expect("serialize Ticket query");
assert!(ticket_json.contains("T-543"));
assert!(ticket_json.contains("O-6"));
assert!(ticket_json.contains("W-12"));
assert!(!ticket_json.contains("00001TICKETINTERNAL"));
assert!(!ticket_json.contains("runtime-internal"));
assert!(!ticket_json.contains("worker-internal"));
let objective = project_objective_query(json!({
"page": {"next_cursor": null, "has_more": false},
"record_authority": "workspace_sqlite",
"items": [{
"id": "00001OBJECTIVEINTERNAL",
"resource_key": "O-6",
"title": "Objective",
"state": "active",
"created_at": null,
"updated_at": null,
"matched_fields": [],
"snippet": null,
"linked_ticket_count": 1,
"linked_tickets": ["00001TICKETINTERNAL"],
"linked_ticket_keys": ["T-543"]
}]
}))
.expect("Objective query projection");
let objective_json = serde_json::to_string(&objective).expect("serialize Objective query");
assert!(objective_json.contains("O-6"));
assert!(objective_json.contains("T-543"));
assert!(objective_json.contains("\"summary\":null"));
assert!(!objective_json.contains("00001OBJECTIVEINTERNAL"));
assert!(!objective_json.contains("00001TICKETINTERNAL"));
}
#[test]
fn resource_projection_rejects_noncanonical_keys() {
for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {
assert!(validate_resource_ref(key.to_string(), prefix).is_err());
}
}
#[test]
fn ticket_projection_fails_closed_without_worker_resource_key() {
let error = project_worker(&json!({"worker_resource_key": null}))
.expect_err("missing W-key must fail");
assert!(error.contains("W-"));
}
}
@@ -193,6 +193,7 @@ impl Tool for ShowOverviewTool {
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"label": entry.label,
@@ -234,15 +235,16 @@ impl Tool for SearchEntriesTool {
.transpose()?;
let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
let view = self.state.view();
if let (Some(from), Some(through)) = (&from, &through) {
if from.source_index() > through.source_index() {
if view.source_index_for_ref(from) > view.source_index_for_ref(through) {
return Err(ToolError::InvalidArgument(
"SearchEntries from must not be after through".to_string(),
));
}
}
let limit = bounded_limit(params.limit, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT);
let hits = self.state.view().search(&SearchOptions {
let hits = view.search(&SearchOptions {
query: params.query,
kind,
tool_part,
@@ -318,6 +320,7 @@ impl Tool for ReadEntryTool {
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
+353 -20
View File
@@ -33,6 +33,8 @@ use crate::feature::{
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use agen::tool::{Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
use super::resource_projection::{project_ticket_detail, project_ticket_query};
#[derive(Clone, Copy)]
enum WorkspaceTicketReadKind {
Query,
@@ -153,8 +155,10 @@ struct WorkspaceQueryTicketInput {
/// stale_after_rescope, and missing_evidence.
#[serde(default)]
attention: Vec<WorkspaceTicketAttentionFilter>,
/// Related Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
related_ticket_id: Option<String>,
relation_kind: Option<WorkspaceTicketRelationFilter>,
/// Linked Objective reference. Prefer `O-*`; canonical internal ids remain accepted for compatibility.
linked_objective_id: Option<String>,
updated_after: Option<String>,
updated_before: Option<String>,
@@ -169,6 +173,7 @@ struct WorkspaceQueryTicketInput {
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
struct WorkspaceShowTicketInput {
/// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility.
id: String,
/// Most-recent thread entries to return, bounded by the Backend to 1..=50.
event_limit: Option<usize>,
@@ -229,13 +234,27 @@ impl Tool for WorkspaceTicketReadTool {
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
if !response.is_success() {
return Err(ToolError::ExecutionFailed(format!(
"Workspace Ticket API returned HTTP {}: {}",
response.status, response.body
"Workspace Ticket API request failed with HTTP status {}",
response.status
)));
}
let response_value: Value = serde_json::from_str(&response.body).map_err(|error| {
ToolError::ExecutionFailed(format!(
"Workspace Ticket API returned invalid JSON: {error}"
))
})?;
let content = match self.kind {
WorkspaceTicketReadKind::Query => serde_json::to_string(
&project_ticket_query(response_value).map_err(ToolError::ExecutionFailed)?,
),
WorkspaceTicketReadKind::Show => serde_json::to_string(
&project_ticket_detail(response_value).map_err(ToolError::ExecutionFailed)?,
),
}
.map_err(|error| ToolError::Internal(error.to_string()))?;
Ok(ToolOutput {
summary: self.kind.name().to_string(),
content: Some(response.body),
content: Some(content),
attachments: Vec::new(),
})
}
@@ -267,18 +286,52 @@ pub const TICKET_SERVICE_ID: &str = "ticket.authority";
const TICKET_SERVICE_VERSION: &str = "1";
pub trait TicketService: Send + Sync {
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError>;
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketHandoff {
pub id: String,
pub resource_key: String,
pub workflow_state: TicketWorkflowState,
}
fn is_canonical_ticket_resource_key(resource_key: &str) -> bool {
resource_key.strip_prefix("T-").is_some_and(|sequence| {
!sequence.is_empty() && sequence.bytes().all(|byte| byte.is_ascii_digit())
})
}
struct BackendTicketService {
backend: TicketToolBackend,
}
struct WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend,
}
fn ticket_handoff_from_record(ticket: Ticket) -> Result<TicketHandoff, TicketError> {
let resource_key = ticket
.meta
.resource_key
.filter(|key| is_canonical_ticket_resource_key(key))
.ok_or_else(|| TicketError::Conflict("ticket resource key is unavailable".into()))?;
Ok(TicketHandoff {
id: ticket.meta.id,
resource_key,
workflow_state: ticket.meta.workflow_state,
})
}
impl TicketService for BackendTicketService {
fn workflow_state(&self, ticket_id: &str) -> Result<TicketWorkflowState, TicketError> {
self.backend
.show(ticket_id.into())
.map(|ticket| ticket.meta.workflow_state)
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
ticket_handoff_from_record(self.backend.show(ticket_ref.into())?)
}
}
impl TicketService for WorkspaceTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
}
}
@@ -600,9 +653,14 @@ impl FeatureModule for TicketFeature {
let Some(backend) = self.tool_backend(context) else {
return Ok(());
};
let ticket_service: Arc<dyn TicketService> = Arc::new(BackendTicketService {
backend: backend.clone(),
});
let ticket_service: Arc<dyn TicketService> = match &self.backend {
TicketFeatureBackend::WorkspaceClient(client) => Arc::new(WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend::new(client.clone()),
}),
TicketFeatureBackend::Local { .. } => Arc::new(BackendTicketService {
backend: backend.clone(),
}),
};
context.services().provide(
ServiceDeclaration::new(
ServiceId::builtin(TICKET_SERVICE_ID),
@@ -674,6 +732,26 @@ impl WorkspaceHttpTicketBackend {
Self::invoke_client(client, workspace_id, operation)
}
fn show_unprojected(&self, ticket_ref: &str) -> TicketResult<Ticket> {
let client = self.client.clone();
let workspace_id = self.client.workspace_id().unwrap_or_default().to_string();
let ticket_path = Self::ticket_path(&TicketIdOrSlug::from(ticket_ref));
let request = move || {
Self::request_unprojected(
client,
WorkspaceRequestMethod::Get,
format!("/api/w/{workspace_id}/tickets/{ticket_path}/record"),
None,
)
};
if tokio::runtime::Handle::try_current().is_ok() {
return std::thread::spawn(request).join().map_err(|_| {
TicketError::Conflict("ticket REST request thread panicked".to_string())
})?;
}
request()
}
fn ticket_path(id: &TicketIdOrSlug) -> String {
let value = match id {
TicketIdOrSlug::Id(value)
@@ -698,6 +776,29 @@ impl WorkspaceHttpTicketBackend {
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<T> {
let mut value = Self::request_value(client, method, endpoint, body)?;
Self::canonicalize_ticket_references(&mut value);
serde_json::from_value(value)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
}
fn request_unprojected<T: serde::de::DeserializeOwned>(
client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod,
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<T> {
let value = Self::request_value(client, method, endpoint, body)?;
serde_json::from_value(value)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
}
fn request_value(
client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod,
endpoint: String,
body: Option<serde_json::Value>,
) -> TicketResult<Value> {
let request = match body {
Some(body) => WorkspaceRequest::json(method, endpoint, body.to_string()),
None if method == WorkspaceRequestMethod::Get => WorkspaceRequest::get(endpoint),
@@ -712,14 +813,63 @@ impl WorkspaceHttpTicketBackend {
})?;
if !response.is_success() {
return Err(TicketError::Conflict(format!(
"ticket REST API returned HTTP {}: {}",
response.status, response.body
"ticket REST API request failed with HTTP status {}",
response.status
)));
}
serde_json::from_str(&response.body)
.map_err(|error| TicketError::Conflict(format!("decode ticket REST response: {error}")))
}
fn canonicalize_ticket_references(value: &mut Value) {
match value {
Value::Array(values) => {
for value in values {
Self::canonicalize_ticket_references(value);
}
}
Value::Object(object) => {
for value in object.values_mut() {
Self::canonicalize_ticket_references(value);
}
if let Some(resource_key) = object
.get("resource_key")
.and_then(Value::as_str)
.filter(|key| is_canonical_ticket_resource_key(key))
.map(ToOwned::to_owned)
&& object.contains_key("id")
{
object.insert("id".to_string(), Value::String(resource_key));
}
}
_ => {}
}
}
fn resolve_ticket_resource_key(
client: Arc<dyn WorkspaceClient>,
base: &str,
reference: &TicketIdOrSlug,
) -> TicketResult<String> {
let response: Value = Self::request(
client,
WorkspaceRequestMethod::Get,
format!("{base}/{}", Self::ticket_path(reference)),
None,
)?;
response
.get("resource_key")
.or_else(|| {
response
.get("meta")
.and_then(|meta| meta.get("resource_key"))
})
.and_then(Value::as_str)
.filter(|key| is_canonical_ticket_resource_key(key))
.map(ToOwned::to_owned)
.ok_or_else(|| TicketError::Conflict("required Ticket key is unavailable".to_string()))
}
fn request_unit(
client: Arc<dyn WorkspaceClient>,
method: WorkspaceRequestMethod,
@@ -739,8 +889,8 @@ impl WorkspaceHttpTicketBackend {
})?;
if !response.is_success() {
return Err(TicketError::Conflict(format!(
"ticket REST API returned HTTP {}: {}",
response.status, response.body
"ticket REST API request failed with HTTP status {}",
response.status
)));
}
Ok(TicketBackendOperationResult::Unit)
@@ -781,12 +931,22 @@ impl WorkspaceHttpTicketBackend {
Ok(TicketBackendOperationResult::Tickets(tickets))
}
TicketBackendOperation::Show { id } => {
let ticket = Self::request(
let ticket: Ticket = Self::request(
client,
WorkspaceRequestMethod::Get,
format!("{base}/{}/record", Self::ticket_path(&id)),
None,
)?;
if !ticket
.meta
.resource_key
.as_deref()
.is_some_and(is_canonical_ticket_resource_key)
{
return Err(TicketError::Conflict(
"required Ticket key is unavailable".to_string(),
));
}
Ok(TicketBackendOperationResult::Ticket(ticket))
}
TicketBackendOperation::Create { input } => {
@@ -889,7 +1049,14 @@ impl WorkspaceHttpTicketBackend {
})?),
),
TicketBackendOperation::AddTicketRelation { id, relation } => {
let relation = Self::request(
let source_resource_key =
Self::resolve_ticket_resource_key(client.clone(), &base, &id)?;
let target_resource_key = Self::resolve_ticket_resource_key(
client.clone(),
&base,
&TicketIdOrSlug::Id(relation.target.clone()),
)?;
let mut relation: TicketRelation = Self::request(
client,
WorkspaceRequestMethod::Post,
format!("{base}/{}/relations", Self::ticket_path(&id)),
@@ -897,20 +1064,30 @@ impl WorkspaceHttpTicketBackend {
TicketError::Conflict(format!("serialize Ticket relation: {error}"))
})?),
)?;
relation.ticket_id = source_resource_key;
relation.target = target_resource_key;
relation.author = "workspace".to_string();
Ok(TicketBackendOperationResult::Relation(relation))
}
TicketBackendOperation::RemoveTicketRelation { id, kind, target } => {
let source_resource_key =
Self::resolve_ticket_resource_key(client.clone(), &base, &id)?;
let target_resource_key =
Self::resolve_ticket_resource_key(client.clone(), &base, &target)?;
let target = match target {
TicketIdOrSlug::Id(value)
| TicketIdOrSlug::Slug(value)
| TicketIdOrSlug::Query(value) => value,
};
let relation = Self::request(
let mut relation: TicketRelation = Self::request(
client,
WorkspaceRequestMethod::Delete,
format!("{base}/{}/relations", Self::ticket_path(&id)),
Some(serde_json::json!({ "kind": kind, "target": target })),
)?;
relation.ticket_id = source_resource_key;
relation.target = target_resource_key;
relation.author = "workspace".to_string();
Ok(TicketBackendOperationResult::Relation(relation))
}
TicketBackendOperation::QueryTicketRelations { ticket, kind } => {
@@ -1245,6 +1422,23 @@ mod tests {
.expect("tool exists")
}
#[test]
fn workspace_ticket_backend_canonicalizes_model_facing_ticket_ids() {
let mut value = serde_json::json!({
"id": "00001INTERNAL",
"resource_key": "T-42",
"nested": {
"id": "00002INTERNAL",
"resource_key": "T-43"
},
"body": "user-authored 00003BODY stays unchanged"
});
WorkspaceHttpTicketBackend::canonicalize_ticket_references(&mut value);
assert_eq!(value["id"], "T-42");
assert_eq!(value["nested"]["id"], "T-43");
assert_eq!(value["body"], "user-authored 00003BODY stays unchanged");
}
#[test]
fn workspace_ticket_reads_expose_bounded_query_and_show_contracts_without_legacy_aliases() {
let client: Arc<dyn WorkspaceClient> = Arc::new(
@@ -1721,11 +1915,102 @@ provider = "github"
server.join().unwrap();
}
#[test]
fn workspace_http_backend_records_relation_with_authoritative_keys() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let server = thread::spawn(move || {
for (expected_path, resource_key) in [
("GET /api/w/workspace-a/tickets/01SOURCE HTTP/1.1", "T-1"),
("GET /api/w/workspace-a/tickets/01TARGET HTTP/1.1", "T-2"),
] {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with(expected_path));
let body = serde_json::json!({"meta": {"resource_key": resource_key}}).to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(), body
)
.unwrap();
}
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(
request.starts_with("POST /api/w/workspace-a/tickets/01SOURCE/relations HTTP/1.1")
);
let body = serde_json::to_string(&TicketRelation {
ticket_id: "01SOURCE".to_string(),
kind: TicketRelationKind::DependsOn,
target: "01TARGET".to_string(),
note: None,
author: "worker-internal".to_string(),
at: "2026-08-06T00:00:00Z".to_string(),
})
.unwrap();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
)
.unwrap();
});
let backend = WorkspaceHttpTicketBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace-a", format!("http://{addr}")),
));
let relation = backend
.add_ticket_relation(
TicketIdOrSlug::Id("01SOURCE".to_string()),
NewTicketRelation {
kind: TicketRelationKind::DependsOn,
target: "01TARGET".to_string(),
note: None,
author: None,
},
)
.unwrap();
server.join().unwrap();
assert_eq!(relation.ticket_id, "T-1");
assert_eq!(relation.target, "T-2");
assert_eq!(relation.author, "workspace");
}
#[test]
fn workspace_http_backend_deletes_exact_ticket_relation() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
for (expected_path, resource_key) in [
("GET /api/w/workspace-a/tickets/01SOURCE HTTP/1.1", "T-1"),
("GET /api/w/workspace-a/tickets/01TARGET HTTP/1.1", "T-2"),
] {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with(expected_path));
let response_body = serde_json::json!({
"meta": {"resource_key": resource_key}
})
.to_string();
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
}
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
@@ -1766,8 +2051,56 @@ provider = "github"
.unwrap();
server.join().unwrap();
assert_eq!(removed.ticket_id, "01SOURCE");
assert_eq!(removed.target, "01TARGET");
assert_eq!(removed.ticket_id, "T-1");
assert_eq!(removed.target, "T-2");
}
#[test]
fn workspace_ticket_service_preserves_internal_identity_for_handoff() {
let temp = TempDir::new().unwrap();
let local = LocalTicketBackend::new(temp.path().join("tickets"));
let created = local.create(NewTicket::new("Ticket handoff")).unwrap();
let mut ticket = local.show(TicketIdOrSlug::Id(created.id.clone())).unwrap();
ticket.meta.resource_key = Some("T-548".to_string());
ticket.meta.workflow_state = TicketWorkflowState::Queued;
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base_url = format!("http://{}", listener.local_addr().unwrap());
let response_body = serde_json::to_string(&ticket).unwrap();
let server = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
assert!(request.starts_with("GET /api/w/workspace-a/tickets/T-548/record HTTP/1.1"));
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
response_body.len(),
response_body
)
.unwrap();
});
let service = WorkspaceTicketService {
backend: WorkspaceHttpTicketBackend::new(Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url),
)),
};
let handoff = service.ticket_handoff("T-548").unwrap();
server.join().unwrap();
assert_eq!(handoff.id, created.id);
assert_eq!(handoff.resource_key, "T-548");
assert_eq!(handoff.workflow_state, TicketWorkflowState::Queued);
}
#[test]
fn ticket_handoff_accepts_only_canonical_ticket_resource_keys() {
assert!(is_canonical_ticket_resource_key("T-482"));
for invalid in ["", "00001KZVNXFNK", "T-", "T-key", "O-482"] {
assert!(!is_canonical_ticket_resource_key(invalid));
}
}
#[test]
@@ -1,11 +1,12 @@
use std::sync::Arc;
#[cfg(test)]
use agen::Item;
use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use async_trait::async_trait;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use session_store::collect_state;
use session_store::{LogEntry, collect_state};
use super::manage_worker::{WORKER_CONTROL_SERVICE_ID, WorkerControlService};
use crate::feature::{
@@ -60,7 +61,27 @@ pub struct WorkerObservationSubject {
#[derive(Debug, Clone)]
pub struct WorkerSessionCapture {
pub segment_id: String,
pub items: Vec<Item>,
pub entries: Vec<agen::HistoryEntry<crate::SessionHistoryMetadata>>,
}
impl WorkerSessionCapture {
pub fn from_log_entries(
segment_id: impl Into<String>,
log_entries: &[LogEntry],
) -> Result<Self, String> {
let segment_id = segment_id.into();
let state = collect_state(log_entries);
let parsed_segment_id = segment_id.parse().unwrap_or_default();
let entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
parsed_segment_id,
log_entries,
)?;
Ok(Self {
segment_id,
entries,
})
}
}
#[derive(Debug, thiserror::Error)]
@@ -161,9 +182,17 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
})
.collect::<Result<Vec<session_store::LogEntry>, _>>()?;
let state = collect_state(&entries);
let segment_id = response.segment_id;
let parsed_segment_id = segment_id.parse().unwrap_or_default();
let typed_entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
parsed_segment_id,
&entries,
)
.map_err(WorkerObservationError::Unavailable)?;
Ok(WorkerSessionCapture {
segment_id: response.segment_id,
items: state.history,
segment_id,
entries: typed_entries,
})
}
}
@@ -392,9 +421,15 @@ impl WorkerObservationProvider for SpawnedSubWorkerObservationProvider {
.ok_or(WorkerObservationError::NotFound)?;
let entries = record.session.entries();
let state = collect_state(&entries);
let typed_entries = crate::session_history::restore_history_entries(
state.session_id.unwrap_or_default(),
Default::default(),
&entries,
)
.map_err(WorkerObservationError::Unavailable)?;
Ok(WorkerSessionCapture {
segment_id: format!("subworker:{name}"),
items: state.history,
entries: typed_entries,
})
}
}
@@ -508,6 +543,7 @@ impl Tool for ViewSessionOverviewTool {
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"label": entry.label,
@@ -547,7 +583,7 @@ impl Tool for SearchSessionEntriesTool {
let from = params.from.as_deref().map(parse_entry_ref).transpose()?;
let through = params.through.as_deref().map(parse_entry_ref).transpose()?;
if let (Some(from), Some(through)) = (&from, &through) {
if from.source_index() > through.source_index() {
if view.source_index_for_ref(from) > view.source_index_for_ref(through) {
return Err(ToolError::InvalidArgument(
"SearchSessionEntries from must not be after through".to_string(),
));
@@ -573,6 +609,7 @@ impl Tool for SearchSessionEntriesTool {
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
@@ -628,6 +665,7 @@ impl Tool for ReadSessionEntryTool {
.map(|entry| {
serde_json::json!({
"entry_ref": entry.id,
"origin": entry.origin,
"entry_range": entry.entry_range,
"kind": entry.kind.as_str(),
"tool_part": entry.tool_part.map(|part| format!("{part:?}").to_lowercase()),
@@ -661,7 +699,10 @@ async fn latest_view(
.capture_worker_session(subject)
.await
.map_err(tool_error)?;
Ok(SessionCapture::new(capture.segment_id, capture.items))
Ok(SessionCapture::from_history_entries(
capture.segment_id,
capture.entries,
))
}
fn parse_input<T: serde::de::DeserializeOwned>(
@@ -751,9 +792,23 @@ mod tests {
if subject != &granted_subject() {
return Err(WorkerObservationError::NotFound);
}
let entries = self
.captures
.lock()
.unwrap()
.clone()
.into_iter()
.enumerate()
.map(|(index, item)| {
let mut metadata = crate::SessionHistoryMetadata::legacy_unknown();
metadata.entry_id =
session_store::LoggedSessionHistoryEntryId(format!("fake-{index:08}"));
agen::HistoryEntry::new(item, metadata)
})
.collect();
Ok(WorkerSessionCapture {
segment_id: "segment".to_string(),
items: self.captures.lock().unwrap().clone(),
entries,
})
}
}
@@ -796,7 +851,7 @@ mod tests {
let read = read_definition(provider.clone())().1;
let hidden = read
.execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"unauthorized"},"entry_ref":"E00000000"}"#,
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"unauthorized"},"entry_ref":"Efake-00000000"}"#,
agen::tool::ToolExecutionContext::direct(),
)
.await
@@ -810,7 +865,7 @@ mod tests {
.push(message("a1", Role::Assistant, "second"));
let output = read
.execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000000"}"#,
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"Efake-00000000"}"#,
agen::tool::ToolExecutionContext::direct(),
)
.await
@@ -819,7 +874,7 @@ mod tests {
let output = read
.execute(
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"E00000001"}"#,
r#"{"subject":{"kind":"runtime_worker","runtime_id":"runtime-1","worker_id":"granted"},"entry_ref":"Efake-00000001"}"#,
agen::tool::ToolExecutionContext::direct(),
)
.await
+292 -25
View File
@@ -10,7 +10,7 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use agen::timeline::event::UsageEvent;
use agen::{Engine, llm_client::LlmClient};
use agen::{Engine, EngineError, llm_client::LlmClient};
use manifest::{Scope, WorkerManifest};
use protocol::{Event, InFlightSnapshot, WorkerStatus};
use session_store::{LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntry};
@@ -43,6 +43,8 @@ pub(crate) struct InternalWorkerAuthority {
pub workspace: WorkerWorkspaceContext,
pub filesystem: WorkerFilesystemAuthority,
pub scope: Scope,
/// Provider-bound session inherited in an attenuated form from the owner.
pub workdir_session: Option<workdir::WorkdirSessionHandle>,
}
pub(crate) struct InternalWorkerSpec {
@@ -53,6 +55,17 @@ pub(crate) struct InternalWorkerSpec {
pub input: String,
pub cache_key: Option<String>,
pub max_turns: Option<u32>,
pub engine_configurator: Option<
Box<
dyn FnOnce(
&mut Engine<
Box<dyn LlmClient>,
agen::state::Mutable,
crate::SessionHistoryMetadata,
>,
) + Send,
>,
>,
pub features: FeatureRegistryBuilder,
pub required_tools: &'static [&'static str],
pub authority: InternalWorkerAuthority,
@@ -102,6 +115,7 @@ where
input,
cache_key,
max_turns,
engine_configurator,
features,
required_tools,
authority,
@@ -120,7 +134,9 @@ where
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
let usage_slot = last_usage.clone();
let mut engine = Engine::new(client).system_prompt(system_prompt);
let mut engine =
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
.system_prompt(system_prompt);
engine.on_usage(move |usage| {
if let Ok(mut slot) = usage_slot.lock() {
*slot = Some(usage.clone());
@@ -128,7 +144,11 @@ where
});
engine.set_cache_key(cache_key);
engine.set_max_turns(max_turns);
if let Some(configure) = engine_configurator {
configure(&mut engine);
}
let store = EphemeralSessionStore::default();
let inherited_workdir_session = authority.workdir_session.clone();
let mut worker = Worker::new(
manifest,
engine,
@@ -144,6 +164,9 @@ where
identity: identity.clone(),
history_entries: 0,
})?;
if let Some(session) = inherited_workdir_session {
worker.bind_workdir_session(Some(session));
}
let install_report = worker.install_features(features);
let installed_tools = install_report.installed_tool_names();
@@ -188,12 +211,28 @@ where
on_cancel_sender(worker.engine_mut().cancel_sender());
match worker.run_text(&input).await {
Ok(lifecycle) => Ok(InternalWorkerResult {
Ok(lifecycle @ WorkerRunResult::Finished)
| Ok(lifecycle @ WorkerRunResult::Paused)
| Ok(lifecycle @ WorkerRunResult::RolledBack) => Ok(InternalWorkerResult {
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
identity,
lifecycle,
history_entries: store.entries_count(session_id, segment_id),
}),
Ok(WorkerRunResult::LimitReached) => Err(InternalWorkerError {
source: WorkerError::Engine(EngineError::Aborted(
"internal Worker reached its turn limit".to_string(),
)),
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
identity,
history_entries: store.entries_count(session_id, segment_id),
}),
Ok(WorkerRunResult::Interrupted { message, .. }) => Err(InternalWorkerError {
source: WorkerError::Engine(EngineError::Aborted(message)),
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
identity,
history_entries: store.entries_count(session_id, segment_id),
}),
Err(source) => Err(InternalWorkerError {
source,
usage: last_usage.lock().ok().and_then(|slot| slot.clone()),
@@ -221,6 +260,7 @@ impl Default for InternalWorkerVisibility {
pub(crate) enum InternalWorkerSessionStatus {
Idle,
Running,
Paused,
Stopping,
Stopped,
Failed,
@@ -231,9 +271,10 @@ impl InternalWorkerSessionStatus {
match self {
Self::Idle => 0,
Self::Running => 1,
Self::Stopping => 2,
Self::Stopped => 3,
Self::Failed => 4,
Self::Paused => 2,
Self::Stopping => 3,
Self::Stopped => 4,
Self::Failed => 5,
}
}
@@ -241,16 +282,37 @@ impl InternalWorkerSessionStatus {
match value {
0 => Self::Idle,
1 => Self::Running,
2 => Self::Stopping,
3 => Self::Stopped,
2 => Self::Paused,
3 => Self::Stopping,
4 => Self::Stopped,
_ => Self::Failed,
}
}
}
fn classify_internal_turn_result(
result: Result<WorkerRunResult, WorkerError>,
) -> (InternalWorkerSessionStatus, Option<String>) {
match result {
Ok(WorkerRunResult::Finished) => (InternalWorkerSessionStatus::Idle, None),
Ok(WorkerRunResult::Paused) => (InternalWorkerSessionStatus::Paused, None),
Ok(WorkerRunResult::LimitReached) => (
InternalWorkerSessionStatus::Stopped,
Some("internal Worker reached its turn limit".to_string()),
),
Ok(WorkerRunResult::Interrupted { message, .. }) => {
(InternalWorkerSessionStatus::Stopped, Some(message))
}
Ok(WorkerRunResult::RolledBack) => (
InternalWorkerSessionStatus::Stopped,
Some("internal Worker run was cancelled before AI output".to_string()),
),
Err(error) => (InternalWorkerSessionStatus::Failed, Some(error.to_string())),
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum InternalWorkerSessionError {
#[cfg(test)]
#[error("failed to build internal Worker session: {message}")]
Build { message: String },
#[error("internal Worker session is busy")]
@@ -343,10 +405,11 @@ impl InternalWorkerSessionHandle {
entries,
status: match self.status() {
InternalWorkerSessionStatus::Running => WorkerStatus::Running,
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
InternalWorkerSessionStatus::Idle => WorkerStatus::Idle,
InternalWorkerSessionStatus::Stopping
| InternalWorkerSessionStatus::Stopped
| InternalWorkerSessionStatus::Failed => WorkerStatus::Paused,
| InternalWorkerSessionStatus::Failed => WorkerStatus::Stopped,
},
error: self.last_error.lock().unwrap().clone(),
in_flight,
@@ -378,6 +441,7 @@ impl InternalWorkerSessionHandle {
.map_err(
|current| match InternalWorkerSessionStatus::decode(current) {
InternalWorkerSessionStatus::Running
| InternalWorkerSessionStatus::Paused
| InternalWorkerSessionStatus::Stopping => InternalWorkerSessionError::Busy,
InternalWorkerSessionStatus::Stopped | InternalWorkerSessionStatus::Failed => {
InternalWorkerSessionError::Stopped
@@ -410,7 +474,6 @@ impl InternalWorkerSessionHandle {
Ok(())
}
#[cfg(test)]
pub(crate) async fn wait_until_idle(&self) -> InternalWorkerSessionStatus {
loop {
let notified = self.state_changed.notified();
@@ -475,6 +538,7 @@ pub(crate) async fn spawn_internal_worker_session(
input,
cache_key,
max_turns,
engine_configurator,
features,
required_tools,
authority,
@@ -484,7 +548,9 @@ pub(crate) async fn spawn_internal_worker_session(
let last_usage = Arc::new(Mutex::new(None::<UsageEvent>));
let usage_slot = last_usage.clone();
let mut engine = Engine::new(client).system_prompt(system_prompt);
let mut engine =
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
.system_prompt(system_prompt);
engine.on_usage(move |usage| {
if let Ok(mut slot) = usage_slot.lock() {
*slot = Some(usage.clone());
@@ -492,7 +558,11 @@ pub(crate) async fn spawn_internal_worker_session(
});
engine.set_cache_key(cache_key);
engine.set_max_turns(max_turns);
if let Some(configure) = engine_configurator {
configure(&mut engine);
}
let store = EphemeralSessionStore::default();
let inherited_workdir_session = authority.workdir_session.clone();
let mut worker = Worker::new(
manifest,
engine,
@@ -505,6 +575,9 @@ pub(crate) async fn spawn_internal_worker_session(
.map_err(|source| InternalWorkerSessionError::Build {
message: source.to_string(),
})?;
if let Some(session) = inherited_workdir_session {
worker.bind_workdir_session(Some(session));
}
let install_report = worker.install_features(features);
let installed_tools = install_report.installed_tool_names();
let install_failed = install_report
@@ -539,6 +612,104 @@ pub(crate) async fn spawn_internal_worker_session(
spawn_prepared_internal_worker_session(worker, store, input, None).await
}
/// Prepare an observable Internal Worker from the same bounded spec as one-shot
/// helpers, but do not start its first turn. The owner must register the returned
/// handle before calling `send`, preserving the snapshot/live boundary.
pub(crate) fn prepare_internal_worker_from_spec(
spec: InternalWorkerSpec,
visibility: InternalWorkerVisibility,
) -> std::pin::Pin<
Box<
dyn std::future::Future<
Output = Result<InternalWorkerSessionHandle, InternalWorkerSessionError>,
> + Send,
>,
> {
Box::pin(async move {
let InternalWorkerSpec {
identity,
mut manifest,
client,
system_prompt,
input: _,
cache_key,
max_turns,
engine_configurator,
features,
required_tools,
authority,
} = spec;
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
manifest.feature = Default::default();
manifest.plugins = Default::default();
manifest.mcp = Default::default();
manifest.skills = None;
manifest.compaction = None;
manifest.memory = None;
let mut engine =
Engine::<_, agen::state::Mutable, crate::SessionHistoryMetadata>::new_annotated(client)
.system_prompt(system_prompt);
engine.set_cache_key(cache_key);
engine.set_max_turns(max_turns);
if let Some(configure) = engine_configurator {
configure(&mut engine);
}
let store = EphemeralSessionStore::default();
let inherited_workdir_session = authority.workdir_session.clone();
let mut worker = Worker::new(
manifest,
engine,
store.clone(),
authority.workspace,
authority.filesystem,
authority.scope,
)
.await
.map_err(|source| InternalWorkerSessionError::Build {
message: source.to_string(),
})?;
if let Some(session) = inherited_workdir_session {
worker.bind_workdir_session(Some(session));
}
let install_report = worker.install_features(features);
let installed_tools = install_report.installed_tool_names();
let install_failed = install_report
.reports
.iter()
.any(|report| !report.installed);
let missing = required_tools
.iter()
.filter(|required| {
!installed_tools
.iter()
.any(|installed| installed == **required)
})
.copied()
.collect::<Vec<_>>();
if install_failed || !missing.is_empty() {
let diagnostics = install_report
.reports
.iter()
.flat_map(|report| report.diagnostics.iter())
.map(|diagnostic| diagnostic.message.as_str())
.collect::<Vec<_>>()
.join("; ");
return Err(InternalWorkerSessionError::Build {
message: format!(
"internal Worker feature installation failed: {diagnostics}; missing tools: {}",
missing.join(", ")
),
});
}
Box::pin(prepare_internal_worker_session(
worker, store, visibility, None, None,
))
.await
})
}
fn spawn_internal_log_event_bridge(sink: SegmentLogSink, event_tx: broadcast::Sender<Event>) {
let (_, mut log_rx) = sink.subscribe_with_snapshot();
tokio::spawn(async move {
@@ -620,13 +791,7 @@ pub(crate) async fn prepare_internal_worker_session(
loop {
tokio::select! {
result = &mut run => {
let (turn_status, error) = match result {
Ok(_) => (InternalWorkerSessionStatus::Idle, None),
Err(error) => (
InternalWorkerSessionStatus::Failed,
Some(error.to_string()),
),
};
let (turn_status, error) = classify_internal_turn_result(result);
actor_in_flight.clear();
status.store(turn_status.encode(), std::sync::atomic::Ordering::Release);
if let Some(message) = error {
@@ -635,11 +800,20 @@ pub(crate) async fn prepare_internal_worker_session(
code: protocol::ErrorCode::Internal,
message,
});
} else {
let _ = event_tx.send(Event::Status {
status: WorkerStatus::Idle,
});
}
let protocol_status = match turn_status {
InternalWorkerSessionStatus::Idle => WorkerStatus::Idle,
InternalWorkerSessionStatus::Paused => WorkerStatus::Paused,
InternalWorkerSessionStatus::Stopped
| InternalWorkerSessionStatus::Failed => WorkerStatus::Stopped,
InternalWorkerSessionStatus::Running
| InternalWorkerSessionStatus::Stopping => {
unreachable!("run completion cannot remain active")
}
};
let _ = event_tx.send(Event::Status {
status: protocol_status,
});
if let Some(callback) = &on_turn_end {
callback(turn_status);
}
@@ -653,7 +827,7 @@ pub(crate) async fn prepare_internal_worker_session(
let _ = (&mut run).await;
actor_in_flight.clear();
status.store(InternalWorkerSessionStatus::Stopped.encode(), std::sync::atomic::Ordering::Release);
let _ = event_tx.send(Event::Status { status: WorkerStatus::Paused });
let _ = event_tx.send(Event::Status { status: WorkerStatus::Stopped });
let _ = event_tx.send(Event::Shutdown);
state_changed.notify_waiters();
let _ = done.send(());
@@ -679,7 +853,7 @@ pub(crate) async fn prepare_internal_worker_session(
std::sync::atomic::Ordering::Release,
);
let _ = event_tx.send(Event::Status {
status: WorkerStatus::Paused,
status: WorkerStatus::Stopped,
});
let _ = event_tx.send(Event::Shutdown);
state_changed.notify_waiters();
@@ -989,6 +1163,26 @@ mod tests {
}
}
#[derive(Clone)]
struct FailingClient;
#[async_trait]
impl LlmClient for FailingClient {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(
&self,
_request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
Err(ClientError::Config(
"intentional internal failure".to_string(),
))
}
}
#[derive(Clone)]
struct CancelBeforeAiClient {
calls: Arc<AtomicUsize>,
@@ -1076,12 +1270,14 @@ permission = "write"
input: "input".to_string(),
cache_key: Some("internal-test".to_string()),
max_turns: Some(1),
engine_configurator: None,
features: FeatureRegistryBuilder::new(),
required_tools,
authority: InternalWorkerAuthority {
workspace: WorkerWorkspaceContext::no_workspace(),
filesystem: WorkerFilesystemAuthority::None,
scope: Scope::empty(),
workdir_session: None,
},
}
}
@@ -1100,6 +1296,77 @@ permission = "write"
assert_eq!(result.identity.kind, "test");
}
#[test]
fn internal_turn_result_mapping_is_exhaustive() {
let cases = [
(
WorkerRunResult::Finished,
InternalWorkerSessionStatus::Idle,
false,
),
(
WorkerRunResult::Paused,
InternalWorkerSessionStatus::Paused,
false,
),
(
WorkerRunResult::LimitReached,
InternalWorkerSessionStatus::Stopped,
true,
),
(
WorkerRunResult::Interrupted {
code: protocol::ErrorCode::Internal,
message: "cancelled".to_string(),
},
InternalWorkerSessionStatus::Stopped,
true,
),
(
WorkerRunResult::RolledBack,
InternalWorkerSessionStatus::Stopped,
true,
),
];
for (result, expected_status, expects_error) in cases {
let (status, error) = classify_internal_turn_result(Ok(result));
assert_eq!(status, expected_status);
assert_eq!(error.is_some(), expects_error);
}
let (status, error) = classify_internal_turn_result(Err(WorkerError::Engine(
EngineError::Aborted("fatal".to_string()),
)));
assert_eq!(status, InternalWorkerSessionStatus::Failed);
assert!(error.is_some_and(|message| message.contains("fatal")));
}
#[tokio::test]
async fn fatal_internal_run_transitions_to_stopped_protocol_status() {
let calls = Arc::new(AtomicUsize::new(0));
let mut internal_spec = spec(calls, &[]);
internal_spec.client = Box::new(FailingClient);
let handle = spawn_internal_worker_session(internal_spec)
.await
.expect("spawn failing Internal Worker session");
assert_eq!(
handle.wait_until_idle().await,
InternalWorkerSessionStatus::Stopped
);
assert_eq!(handle.status(), InternalWorkerSessionStatus::Stopped);
assert_eq!(handle.protocol_snapshot().status, WorkerStatus::Stopped);
assert!(
handle
.last_error
.lock()
.unwrap()
.as_ref()
.is_some_and(|message| message.contains("intentional internal failure"))
);
}
#[tokio::test]
async fn session_accepts_follow_up_turns_and_stops_without_runtime_registration() {
let calls = Arc::new(AtomicUsize::new(0));
+13 -2
View File
@@ -13,7 +13,7 @@
#[cfg(test)]
use crate::prompt::catalog::PromptCatalog;
use agen::Item;
use agen::{Item, ToolResultDisposition};
/// Build synthetic `Item::ToolResult` items for every unanswered
/// `Item::ToolCall` in `history`, preserving order.
@@ -28,7 +28,16 @@ pub(crate) fn orphan_tool_result_closures(history: &[Item], summary: &str) -> Ve
for item in history {
if let Item::ToolCall { call_id, .. } = item {
if !answered.contains(call_id.as_str()) {
out.push(Item::tool_result(call_id.clone(), summary));
out.push(Item::tool_result_item_with_disposition_and_attachments(
call_id.clone(),
summary,
Some(
"Execution ended before completion could be confirmed. Completion and side effects are unknown."
.to_string(),
),
ToolResultDisposition::OutcomeUnknown,
Vec::new(),
));
}
}
}
@@ -77,10 +86,12 @@ mod tests {
Item::ToolResult {
call_id,
summary: got,
disposition,
..
} => {
assert_eq!(call_id, "c1");
assert_eq!(got, &summary);
assert_eq!(*disposition, ToolResultDisposition::OutcomeUnknown);
}
other => panic!("expected ToolResult, got {other:?}"),
}
+39 -2
View File
@@ -8,6 +8,7 @@
//! decisions (continue / skip / abort / pause).
use std::borrow::Cow;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
@@ -33,7 +34,9 @@ use crate::hook::{
};
use crate::ipc::notify_buffer::{NotifyBuffer, build_system_item_with_provenance};
use crate::prompt::catalog::PromptCatalog;
use crate::session_history::SessionHistoryMetadata;
use crate::worker::SystemItemCommitter;
use agen::HistoryEntry;
use agen::token_counter::total_tokens;
/// Maximum number of bytes copied into `TurnEndInfo::final_text_preview`.
@@ -73,6 +76,7 @@ pub(crate) struct WorkerInterceptor {
/// worker. `None` in tests / `Worker::new` paths where no writer is
/// attached.
log_writer: Option<Arc<dyn SystemItemCommitter>>,
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
/// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize,
/// Tool calls observed in the current turn (reset on each new prompt).
@@ -80,6 +84,7 @@ pub(crate) struct WorkerInterceptor {
}
impl WorkerInterceptor {
#[cfg(test)]
pub(crate) fn new(
registry: Arc<HookRegistry>,
compact_state: Option<Arc<CompactState>>,
@@ -88,6 +93,28 @@ impl WorkerInterceptor {
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
prompts: Arc<ArcSwap<PromptCatalog>>,
log_writer: Option<Arc<dyn SystemItemCommitter>>,
) -> Self {
Self::new_with_history_queue(
registry,
compact_state,
usage_history,
pending_notifies,
pending_attachments,
prompts,
log_writer,
Arc::new(Mutex::new(VecDeque::new())),
)
}
pub(crate) fn new_with_history_queue(
registry: Arc<HookRegistry>,
compact_state: Option<Arc<CompactState>>,
usage_history: Option<Arc<Mutex<Vec<UsageRecord>>>>,
pending_notifies: NotifyBuffer,
pending_attachments: Arc<Mutex<Vec<SystemItem>>>,
prompts: Arc<ArcSwap<PromptCatalog>>,
log_writer: Option<Arc<dyn SystemItemCommitter>>,
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
) -> Self {
Self {
registry,
@@ -99,6 +126,7 @@ impl WorkerInterceptor {
prompts,
prompt_workspace_id: None,
log_writer,
pending_committed_history,
next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0),
}
@@ -125,7 +153,11 @@ impl WorkerInterceptor {
return Ok(());
};
for item in items {
writer.commit_system_item(item.clone())?;
let entry = writer.commit_system_item(item.clone())?;
self.pending_committed_history
.lock()
.expect("pending committed history poisoned")
.push_back(entry);
}
Ok(())
}
@@ -507,7 +539,12 @@ mod tests {
&self,
entry: session_store::LogEntry,
) -> Result<(), session_store::StoreError> {
if let session_store::LogEntry::SystemItem { item, .. } = entry {
let item = match entry {
session_store::LogEntry::SystemItem { item, .. } => Some(item),
session_store::LogEntry::AnnotatedSystemItem { entry, .. } => Some(entry.item),
_ => None,
};
if let Some(item) = item {
self.committed
.lock()
.expect("committed system-item list poisoned")
+8 -2
View File
@@ -29,15 +29,21 @@ pub fn subscribe_worker_protocol_session(handle: &WorkerHandle) -> WorkerProtoco
pub fn live_log_entry_event(entry: LogEntry) -> Option<Event> {
match entry {
LogEntry::SegmentStart { .. } => {
entry @ (LogEntry::SegmentStart { .. } | LogEntry::AnnotatedSegmentStart { .. }) => {
let value = serde_json::to_value(&entry).expect("LogEntry is Serialize");
Some(Event::SegmentRotated { entry: value })
}
LogEntry::UserInput { segments, .. } => Some(Event::UserMessage { segments }),
LogEntry::UserInput { segments, .. } | LogEntry::AnnotatedUserInput { segments, .. } => {
Some(Event::UserMessage { segments })
}
LogEntry::SystemItem { item, .. } => {
let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value })
}
LogEntry::AnnotatedSystemItem { entry, .. } => {
let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize");
Some(Event::SystemItem { item: value })
}
LogEntry::Invoke { trigger, .. } => Some(Event::InvokeStart { kind: trigger }),
other => {
// `SegmentLogSink::is_live_relevant` keeps non-live-relevant
+8 -2
View File
@@ -12,6 +12,7 @@ pub mod prompt;
pub mod runtime;
pub mod segment_log_sink;
mod session_capture;
mod session_history;
pub mod shared_state;
mod shutdown_after_idle;
pub mod skill;
@@ -33,14 +34,19 @@ pub use manifest::{
};
pub use model_client::{ProviderError, build_client};
pub use prompt::catalog::{
CatalogError, EffectivePromptCatalog, PromptCatalog, WorkerPrompt, WorkspacePromptProjection,
prompt_schema_source,
CatalogError, EffectivePromptCatalog, OrchestratorQueueAttentionContext,
OrchestratorQueueAttentionPrompt, OrchestratorQueueAttentionTicket, PromptCatalog,
WorkerPrompt, WorkspacePromptProjection, prompt_schema_source,
};
pub use prompt::source::PromptCatalogSource;
pub use prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
pub use protocol::{ErrorCode, Event, Method, TurnResult, WorkerStatus};
pub use runtime::dir::RuntimeDir;
pub use segment_log_sink::SegmentLogSink;
pub use session_history::{
SessionHistoryDerivation, SessionHistoryEntryId, SessionHistoryMetadata,
WorkerHistoryProvenance, WorkerSubjectSnapshot,
};
pub use shared_state::WorkerSharedState;
pub use worker::{
LocalWorkingDirectory, WORKER_INPUT_SUBMISSION_EXTENSION_DOMAIN, Worker, WorkerError,
+1 -1
View File
@@ -34,7 +34,7 @@ impl PermissionHook {
}
}
impl<C: LlmClient, St: Store> Worker<C, St> {
impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
pub(crate) fn apply_permissions_from_manifest(&mut self) {
let Some(permissions) = self.manifest().permissions.clone() else {
return;
+196 -3
View File
@@ -102,7 +102,6 @@ pub enum WorkerPrompt {
AgentsMdSection,
ResidentMemorySummarySection,
WorkerOrchestrationGuidanceSection,
TicketEventCompanionNotice,
SubWorkerSpawnToolDescription,
}
@@ -122,7 +121,6 @@ impl WorkerPrompt {
Self::WorkerOrchestrationGuidanceSection => {
"internal.worker_orchestration_guidance_section"
}
Self::TicketEventCompanionNotice => "worker.ticket_event_companion_notice",
Self::SubWorkerSpawnToolDescription => "internal.sub_worker_spawn_tool_description",
}
}
@@ -139,13 +137,97 @@ impl WorkerPrompt {
WorkerPrompt::AgentsMdSection,
WorkerPrompt::ResidentMemorySummarySection,
WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::TicketEventCompanionNotice,
WorkerPrompt::SubWorkerSpawnToolDescription,
];
}
/// Model-visible queued Ticket projection shared by Server and TUI backlog attention paths.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrchestratorQueueAttentionTicket {
resource_key: String,
title: String,
}
impl OrchestratorQueueAttentionTicket {
pub fn new(
resource_key: impl Into<String>,
title: impl Into<String>,
) -> Result<Self, CatalogError> {
let resource_key = resource_key.into();
if !is_ticket_resource_key(&resource_key) {
return Err(CatalogError::InvalidQueueAttentionResourceKey);
}
Ok(Self {
resource_key,
title: bounded_queue_attention_text(&title.into(), 240),
})
}
}
/// Shared model-visible context for every Orchestrator backlog attention renderer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OrchestratorQueueAttentionContext {
tickets: Vec<OrchestratorQueueAttentionTicket>,
separator: &'static str,
omitted_ticket_count: usize,
}
impl OrchestratorQueueAttentionContext {
pub const MAX_TICKETS: usize = 20;
pub fn new(tickets: Vec<OrchestratorQueueAttentionTicket>) -> Self {
let omitted_ticket_count = tickets.len().saturating_sub(Self::MAX_TICKETS);
Self {
tickets: tickets.into_iter().take(Self::MAX_TICKETS).collect(),
separator: "",
omitted_ticket_count,
}
}
}
/// Prompt-catalog entries that must share the same backlog-attention body contract.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrchestratorQueueAttentionPrompt {
Server,
Tui,
}
impl OrchestratorQueueAttentionPrompt {
fn key(self) -> &'static str {
match self {
Self::Server => "internal.workspace_orchestrator_queue_attention",
Self::Tui => "panel.orchestrator_idle_queue_notice",
}
}
}
fn is_ticket_resource_key(input: &str) -> bool {
input.len() <= 32
&& input.strip_prefix("T-").is_some_and(|suffix| {
!suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())
})
}
fn bounded_queue_attention_text(input: &str, max_chars: usize) -> String {
let mut output = String::new();
for (index, character) in input.chars().enumerate() {
if index == max_chars {
output.push('…');
break;
}
output.push(if character.is_control() {
' '
} else {
character
});
}
output
}
#[derive(Debug, Error)]
pub enum CatalogError {
#[error("queued Ticket resource key is missing or invalid")]
InvalidQueueAttentionResourceKey,
#[error("failed to build builtin Prompt source tree: {0}")]
BuiltinTree(String),
#[error("failed to evaluate builtin Prompt source tree: {0}")]
@@ -322,6 +404,14 @@ impl PromptCatalog {
self.render_name(key, Value::from_serialize(context))
}
pub fn orchestrator_queue_attention(
&self,
prompt: OrchestratorQueueAttentionPrompt,
context: &OrchestratorQueueAttentionContext,
) -> Result<String, CatalogError> {
self.render_serializable(prompt.key(), context)
}
pub fn render_name(&self, key: &str, ctx: Value) -> Result<String, CatalogError> {
let template = self
.env
@@ -593,6 +683,12 @@ mod tests {
fn builtin_dcdl_catalog_loads() {
let catalog = PromptCatalog::builtins_only().unwrap();
assert!(!catalog.projection.templates.is_empty());
assert!(
!catalog
.projection
.templates
.contains_key("worker.ticket_event_companion_notice")
);
}
#[test]
@@ -609,6 +705,103 @@ mod tests {
assert!(!prompt.contains("use the Ticket repository `origin` transport"));
}
#[test]
fn merge_request_prompts_use_selector_refs_and_verb_first_operations() {
let catalog = PromptCatalog::builtins_only().unwrap();
let templates = &catalog.projection.templates;
let common = &templates["common.merge_request"];
let coder = &templates["role.coder"];
let orchestrator = &templates["role.orchestrator"];
let reviewer = &templates["role.reviewer"];
let combined = format!("{common}\n{coder}\n{orchestrator}\n{reviewer}");
for operation in [
"OpenMergeRequest",
"ShowMergeRequest",
"ReviewMergeRequest",
"CheckMergeRequestReadiness",
"CompleteMergeRequest",
] {
assert!(combined.contains(operation), "missing {operation}");
}
for stale_operation in [
"MergeRequestOpen",
"MergeRequestShow",
"MergeRequestReview",
"MergeRequestReadinessCheck",
"MergeRequestComplete",
"MergeRequestAddRevision",
] {
assert!(
!combined.contains(stale_operation),
"stale operation {stale_operation} remains in prompt authority"
);
}
assert!(common.contains("selector_from"));
assert!(common.contains("normal non-force push"));
assert!(common.contains("Moving only the target ref does not invalidate approval"));
assert!(common.contains("take precedence over stale Memory"));
assert!(coder.contains("Never invent an add-revision operation"));
assert!(orchestrator.contains("Target-only movement preserves source approval"));
assert!(reviewer.contains("target-only movement does not invalidate approval"));
}
#[test]
fn queue_attention_prompts_share_sanitized_contract_and_true_truncation() {
let catalog = PromptCatalog::builtins_only().unwrap();
let tickets = (1..=OrchestratorQueueAttentionContext::MAX_TICKETS + 1)
.map(|index| {
OrchestratorQueueAttentionTicket::new(
format!("T-{index}"),
format!("Ticket {index}\nwith control\u{7}"),
)
.unwrap()
})
.collect();
let context = OrchestratorQueueAttentionContext::new(tickets);
let server = catalog
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
.unwrap();
let tui = catalog
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Tui, &context)
.unwrap();
assert_eq!(server, tui);
assert!(server.starts_with("Queued Tickets require attention:"));
assert!(server.contains("- T-1 — Ticket 1 with control "));
assert!(!server.contains("T-21"));
assert!(server.contains("were omitted from this notice: 1"));
assert!(server.contains("Re-query current Ticket authority"));
assert!(server.contains("Reread the current Ticket state before acting"));
for secret in [
"workspace_id",
"Workspace:",
"runtime_id",
"worker_id",
"bounded",
] {
assert!(!server.contains(secret), "leaked {secret}: {server}");
}
}
#[test]
fn queue_attention_prompt_omits_truncation_text_for_complete_list() {
let catalog = PromptCatalog::builtins_only().unwrap();
let context = OrchestratorQueueAttentionContext::new(vec![
OrchestratorQueueAttentionTicket::new("T-541", "Attention contract").unwrap(),
]);
let rendered = catalog
.orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context)
.unwrap();
assert!(rendered.contains("- T-541 — Attention contract"));
assert!(!rendered.contains("omitted"));
assert!(matches!(
OrchestratorQueueAttentionTicket::new("opaque-id", "must fail"),
Err(CatalogError::InvalidQueueAttentionResourceKey)
));
}
#[test]
fn graph_rejects_dynamic_legacy_missing_and_cycles() {
let invalid = BTreeMap::from([
+3
View File
@@ -121,8 +121,11 @@ impl SegmentLogSink {
matches!(
entry,
LogEntry::SegmentStart { .. }
| LogEntry::AnnotatedSegmentStart { .. }
| LogEntry::UserInput { .. }
| LogEntry::AnnotatedUserInput { .. }
| LogEntry::SystemItem { .. }
| LogEntry::AnnotatedSystemItem { .. }
| LogEntry::Invoke { .. }
)
}
+146 -20
View File
@@ -6,7 +6,8 @@
use std::sync::Arc;
use agen::{Item, Role};
use crate::session_history::{SessionHistoryMetadata, WorkerHistoryProvenance};
use agen::{HistoryEntry, Item, Role};
use serde::{Deserialize, Serialize};
const DEFAULT_SEARCH_LIMIT: usize = 20;
@@ -21,14 +22,21 @@ const OVERVIEW_ANCHOR_STRIDE: usize = 8;
pub(crate) struct SessionEntryRef(String);
impl SessionEntryRef {
pub(crate) fn new(source_index: usize) -> Self {
Self(format!("E{source_index:08}"))
pub(crate) fn from_history_entry_id(entry_id: &crate::SessionHistoryEntryId) -> Self {
Self(format!("E{}", entry_id.0))
}
pub(crate) fn parse(value: &str) -> Option<Self> {
let reference = Self(value.to_string());
reference.source_index()?;
Some(reference)
let suffix = value.strip_prefix('E')?;
if suffix.is_empty()
|| suffix.len() > 64
|| !suffix
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return None;
}
Some(Self(value.to_string()))
}
pub(crate) fn as_str(&self) -> &str {
@@ -97,6 +105,7 @@ impl ToolPart {
#[derive(Debug, Clone)]
pub(crate) struct OverviewItem {
pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub label: String,
@@ -107,6 +116,7 @@ pub(crate) struct OverviewItem {
#[derive(Debug, Clone)]
pub(crate) struct ReferenceEntry {
pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
@@ -132,6 +142,7 @@ pub(crate) struct SearchOptions {
#[derive(Debug, Clone)]
pub(crate) struct SearchHit {
pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
@@ -143,6 +154,7 @@ pub(crate) struct SearchHit {
#[derive(Debug, Clone, Copy)]
pub(crate) enum ReadSelector<'a> {
Id(&'a str),
#[cfg(test)]
EntryRange([u64; 2]),
}
@@ -176,6 +188,7 @@ impl Default for ReadOptions {
#[derive(Debug, Clone)]
pub(crate) struct ReadEntry {
pub id: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
pub tool_name: Option<String>,
@@ -194,6 +207,7 @@ pub(crate) struct ReadResult {
pub(crate) struct SessionEntryEvidence {
pub segment_id: String,
pub entry_ref: SessionEntryRef,
pub origin: WorkerHistoryProvenance,
pub entry_range: [u64; 2],
pub kind: ReferenceKind,
pub tool_part: Option<ToolPart>,
@@ -205,26 +219,42 @@ pub(crate) struct SessionEntryEvidence {
#[derive(Debug, Clone)]
pub(crate) struct SessionCapture {
segment_id: String,
items: Arc<Vec<Item>>,
entries: Arc<Vec<HistoryEntry<SessionHistoryMetadata>>>,
overview: Vec<OverviewItem>,
index: Vec<ReferenceEntry>,
}
impl SessionCapture {
pub(crate) fn new(segment_id: impl Into<String>, items: Vec<Item>) -> Self {
let entries = items
.into_iter()
.enumerate()
.map(|(index, item)| {
let mut metadata = SessionHistoryMetadata::legacy_unknown();
metadata.entry_id =
session_store::LoggedSessionHistoryEntryId(format!("{index:08}"));
HistoryEntry::new(item, metadata)
})
.collect();
Self::from_history_entries(segment_id, entries)
}
pub(crate) fn from_history_entries(
segment_id: impl Into<String>,
entries: Vec<HistoryEntry<SessionHistoryMetadata>>,
) -> Self {
let segment_id = segment_id.into();
let items = Arc::new(items);
let entries = Arc::new(entries);
let mut overview = Vec::new();
let mut index = Vec::new();
for (idx, item) in items.iter().enumerate() {
for (idx, entry) in entries.iter().enumerate() {
let item = &entry.item;
let entry_range = [idx as u64, idx as u64];
match item {
Item::Message { role, content, .. } => {
let kind = match role {
Role::User => ReferenceKind::User,
Role::Assistant => ReferenceKind::Assistant,
Role::System => continue,
let Some(kind) = message_reference_kind(&entry.annotation.origin, role) else {
continue;
};
let text = content
.iter()
@@ -233,9 +263,10 @@ impl SessionCapture {
.join("");
let label = format!("{} message", kind.as_str());
let summary = truncate_chars(&text, 240);
let id = SessionEntryRef::new(idx);
let id = SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id);
index.push(ReferenceEntry {
id: id.clone(),
origin: entry.annotation.origin.clone(),
entry_range,
kind,
tool_part: None,
@@ -247,6 +278,7 @@ impl SessionCapture {
if matches!(kind, ReferenceKind::User | ReferenceKind::Assistant) {
overview.push(OverviewItem {
id: id.clone(),
origin: entry.annotation.origin.clone(),
entry_range,
kind,
label,
@@ -260,7 +292,8 @@ impl SessionCapture {
} => {
let text = format!("{name}\n{arguments}");
index.push(ReferenceEntry {
id: SessionEntryRef::new(idx),
id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
origin: entry.annotation.origin.clone(),
entry_range,
kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Input),
@@ -286,7 +319,8 @@ impl SessionCapture {
content.as_deref().unwrap_or_default(),
);
index.push(ReferenceEntry {
id: SessionEntryRef::new(idx),
id: SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id),
origin: entry.annotation.origin.clone(),
entry_range,
kind: ReferenceKind::Tool,
tool_part: Some(ToolPart::Output),
@@ -326,7 +360,7 @@ impl SessionCapture {
Self {
segment_id,
items,
entries,
overview,
index,
}
@@ -336,6 +370,14 @@ impl SessionCapture {
&self.overview
}
pub(crate) fn source_index_for_ref(&self, reference: &SessionEntryRef) -> Option<u64> {
self.index
.iter()
.find(|entry| entry.id == *reference)
.map(|entry| entry.entry_range[0])
.or_else(|| reference.source_index())
}
pub(crate) fn search(&self, options: &SearchOptions) -> Vec<SearchHit> {
let query = options.query.trim().to_lowercase();
let limit = options
@@ -346,12 +388,12 @@ impl SessionCapture {
let min_entry_index = options
.from
.as_ref()
.and_then(SessionEntryRef::source_index)
.and_then(|reference| self.source_index_for_ref(reference))
.unwrap_or_else(|| options.min_entry_index.unwrap_or(0));
let max_entry_index = options
.through
.as_ref()
.and_then(SessionEntryRef::source_index)
.and_then(|reference| self.source_index_for_ref(reference))
.unwrap_or(u64::MAX);
let mut skipped = 0usize;
let mut hits = Vec::new();
@@ -390,6 +432,7 @@ impl SessionCapture {
}
hits.push(SearchHit {
id: entry.id.clone(),
origin: entry.origin.clone(),
kind: entry.kind,
tool_part: entry.tool_part,
tool_name: entry.tool_name.clone(),
@@ -418,6 +461,7 @@ impl SessionCapture {
.iter()
.filter(|entry| entry.id.as_str() == id)
.collect(),
#[cfg(test)]
ReadSelector::EntryRange([start, end]) => self
.index
.iter()
@@ -440,13 +484,18 @@ impl SessionCapture {
}
}
}
let Some(item) = self.items.get(entry.entry_range[0] as usize) else {
let Some(item) = self
.entries
.get(entry.entry_range[0] as usize)
.map(|entry| &entry.item)
else {
continue;
};
let text = render_item(item, entry, options.detail, max_bytes.saturating_sub(bytes));
bytes = bytes.saturating_add(text.len());
entries.push(ReadEntry {
id: entry.id.clone(),
origin: entry.origin.clone(),
kind: entry.kind,
tool_part: entry.tool_part,
tool_name: entry.tool_name.clone(),
@@ -483,6 +532,7 @@ impl SessionCapture {
Some(SessionEntryEvidence {
segment_id: self.segment_id.clone(),
entry_ref: entry.id.clone(),
origin: entry.origin.clone(),
entry_range: entry.entry_range,
kind: entry.kind,
tool_part: entry.tool_part,
@@ -493,6 +543,28 @@ impl SessionCapture {
}
}
fn message_reference_kind(
origin: &WorkerHistoryProvenance,
provider_role: &Role,
) -> Option<ReferenceKind> {
match origin {
WorkerHistoryProvenance::HumanInput { .. }
| WorkerHistoryProvenance::WorkerInput { .. } => Some(ReferenceKind::User),
WorkerHistoryProvenance::ModelOutput { .. } => Some(ReferenceKind::Assistant),
WorkerHistoryProvenance::ToolOutput { .. } => Some(ReferenceKind::Tool),
WorkerHistoryProvenance::LegacyUnknown => match provider_role {
Role::User => Some(ReferenceKind::User),
Role::Assistant => Some(ReferenceKind::Assistant),
Role::System => None,
},
// Flow/backend/system content remains out of the observation surface
// even when represented with a provider user/system role.
WorkerHistoryProvenance::FlowInstruction { .. }
| WorkerHistoryProvenance::BackendInstruction { .. }
| WorkerHistoryProvenance::DerivedSummary => None,
}
}
fn render_item(
item: &Item,
entry: &ReferenceEntry,
@@ -561,6 +633,60 @@ fn truncate_chars(text: &str, max_chars: usize) -> String {
mod tests {
use super::*;
#[test]
fn flow_user_role_is_excluded_while_explicit_human_origin_remains_evidence() {
let entries = vec![
crate::session_history::history_entry(
Item::user_message("trusted flow instruction"),
WorkerHistoryProvenance::FlowInstruction {
selector: "builtin:coder-review".into(),
definition_id: "coder-review".into(),
definition_revision: 3,
instance_id: "instance".into(),
state_id: "implement".into(),
},
),
crate::session_history::history_entry(
Item::user_message("remember my preference"),
WorkerHistoryProvenance::HumanInput {
account_id: "account-1".into(),
},
),
];
let capture = SessionCapture::from_history_entries("segment", entries);
let overview = capture.overview();
assert_eq!(overview.len(), 1);
assert!(matches!(
overview[0].origin,
WorkerHistoryProvenance::HumanInput { .. }
));
let evidence = capture.evidence_for(overview[0].id.as_str()).unwrap();
assert!(evidence.excerpt.ends_with("remember my preference"));
assert!(matches!(
evidence.origin,
WorkerHistoryProvenance::HumanInput { .. }
));
}
#[test]
fn stable_logical_ref_survives_retention_and_restore_projection() {
let retained = crate::session_history::history_entry(
Item::assistant_message("retained"),
WorkerHistoryProvenance::ModelOutput {
worker: crate::session_history::worker_subject(Default::default()),
},
);
let expected_ref = SessionEntryRef::from_history_entry_id(&retained.annotation.entry_id);
let before = SessionCapture::from_history_entries("old", vec![retained.clone()]);
let after = SessionCapture::from_history_entries("new", vec![retained]);
assert_eq!(before.overview()[0].id, expected_ref);
assert_eq!(after.overview()[0].id, expected_ref);
assert_eq!(
after.evidence_for(expected_ref.as_str()).unwrap().entry_ref,
expected_ref
);
}
#[test]
fn overview_contains_user_and_assistant_only() {
let view = SessionCapture::new(
+219
View File
@@ -0,0 +1,219 @@
//! Restore-authoritative metadata for model-visible Worker history.
//!
//! Agen transports this annotation without interpreting it. Session Log v2
//! stores each item and metadata in one typed record; legacy records are
//! retained only as explicit `LegacyUnknown` entries.
use agen::{HistoryEntry, Item};
use protocol::Segment;
use session_store::{
LogEntry, LoggedHistoryDerivation, LoggedHistoryEntry, LoggedSessionHistoryEntryId,
LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedWorkerSubject, SegmentId,
SessionId,
};
pub type SessionHistoryEntryId = LoggedSessionHistoryEntryId;
pub type SessionHistoryMetadata = LoggedSessionHistoryMetadata;
pub type WorkerHistoryProvenance = LoggedSessionHistoryOrigin;
pub type SessionHistoryDerivation = LoggedHistoryDerivation;
pub type WorkerSubjectSnapshot = LoggedWorkerSubject;
pub(crate) fn worker_subject(session_id: SessionId) -> WorkerSubjectSnapshot {
WorkerSubjectSnapshot {
workspace_id: None,
runtime_id: None,
worker_id: session_id.to_string(),
}
}
pub(crate) fn metadata(
origin: WorkerHistoryProvenance,
derivation: Option<SessionHistoryDerivation>,
) -> SessionHistoryMetadata {
SessionHistoryMetadata {
entry_id: SessionHistoryEntryId::new(),
origin,
derivation,
}
}
pub(crate) fn history_entry(
item: Item,
origin: WorkerHistoryProvenance,
) -> HistoryEntry<SessionHistoryMetadata> {
HistoryEntry::new(item, metadata(origin, None))
}
pub(crate) fn to_logged_history_entry(
entry: &HistoryEntry<SessionHistoryMetadata>,
) -> LoggedHistoryEntry {
LoggedHistoryEntry {
item: entry.item.clone().into(),
metadata: entry.annotation.clone(),
}
}
fn legacy_entry(item: Item) -> HistoryEntry<SessionHistoryMetadata> {
HistoryEntry::new(item, SessionHistoryMetadata::legacy_unknown())
}
fn from_logged(entry: &LoggedHistoryEntry) -> HistoryEntry<SessionHistoryMetadata> {
HistoryEntry::new(Item::from(entry.item.clone()), entry.metadata.clone())
}
/// Rebuild typed Worker history directly from the append-only Session Log.
/// Missing legacy metadata is never inferred from role or plaintext.
pub(crate) fn restore_history_entries(
_session_id: SessionId,
_segment_id: SegmentId,
entries: &[LogEntry],
) -> Result<Vec<HistoryEntry<SessionHistoryMetadata>>, String> {
let mut history = Vec::new();
for entry in entries {
match entry {
LogEntry::AnnotatedSegmentStart { history: seed, .. } => {
history = seed.iter().map(from_logged).collect();
}
LogEntry::SegmentStart { history: seed, .. } => {
history = seed
.iter()
.cloned()
.map(Item::from)
.map(legacy_entry)
.collect();
}
LogEntry::AnnotatedUserInput { history: input, .. } => {
history.extend(input.iter().map(from_logged))
}
LogEntry::UserInput { segments, .. } => history.push(legacy_entry(Item::user_message(
Segment::flatten_to_text(segments),
))),
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => history.push(from_logged(entry)),
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
history.push(legacy_entry(Item::from(item.clone())));
}
LogEntry::AnnotatedSystemItem { entry, .. } => history.push(HistoryEntry::new(
entry.item.to_history_item(),
entry.metadata.clone(),
)),
LogEntry::SystemItem { item, .. } => {
history.push(legacy_entry(item.to_history_item()));
}
_ => {}
}
}
Ok(history)
}
#[cfg(test)]
mod tests {
use super::*;
use agen::llm_client::RequestConfig;
use session_store::LogEntry;
#[test]
fn legacy_user_role_is_not_inferred_as_human_authority() {
let entries = vec![LogEntry::UserInput {
ts: 1,
segments: vec![Segment::text("legacy")],
extensions: Vec::new(),
}];
let restored =
restore_history_entries(SessionId::now_v7(), SegmentId::now_v7(), &entries).unwrap();
assert!(matches!(
restored[0].annotation.origin,
WorkerHistoryProvenance::LegacyUnknown
));
}
#[test]
fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() {
let session_id = SessionId::now_v7();
let projected = vec![
history_entry(
Item::user_message("flow instructions"),
WorkerHistoryProvenance::FlowInstruction {
selector: "builtin:coder-review".to_string(),
definition_id: "coder-review".to_string(),
definition_revision: 7,
instance_id: "flow-instance".to_string(),
state_id: "implement".to_string(),
},
),
history_entry(
Item::user_message("implement"),
WorkerHistoryProvenance::LegacyUnknown,
),
];
let entries = vec![
LogEntry::AnnotatedSegmentStart {
ts: 0,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: Vec::new(),
forked_from: None,
compacted_from: None,
},
LogEntry::AnnotatedUserInput {
ts: 1,
segments: vec![
Segment::Flow {
selector: "builtin:coder-review".to_string(),
},
Segment::text("implement"),
],
extensions: Vec::new(),
history: projected.iter().map(to_logged_history_entry).collect(),
},
];
let restored = restore_history_entries(session_id, SegmentId::now_v7(), &entries).unwrap();
assert_eq!(restored, projected);
}
#[test]
fn annotated_restore_preserves_logical_ids_across_reboot() {
let session_id = SessionId::now_v7();
let entry = history_entry(
Item::assistant_message("persisted"),
WorkerHistoryProvenance::ModelOutput {
worker: worker_subject(session_id),
},
);
let log = vec![LogEntry::AnnotatedSegmentStart {
ts: 0,
session_id,
system_prompt: None,
config: RequestConfig::default(),
history: vec![to_logged_history_entry(&entry)],
forked_from: None,
compacted_from: None,
}];
let first = restore_history_entries(session_id, SegmentId::now_v7(), &log).unwrap();
let second = restore_history_entries(session_id, SegmentId::now_v7(), &log).unwrap();
assert_eq!(first[0].annotation.entry_id, entry.annotation.entry_id);
assert_eq!(second[0].annotation.entry_id, entry.annotation.entry_id);
}
#[test]
fn compacted_derivation_uses_stable_logical_entry_ids() {
let source = history_entry(
Item::user_message("source"),
WorkerHistoryProvenance::LegacyUnknown,
);
let summary = HistoryEntry::new(
Item::system_message("summary"),
metadata(
WorkerHistoryProvenance::DerivedSummary,
Some(SessionHistoryDerivation {
sources: vec![source.annotation.entry_id.clone()],
}),
),
);
assert_eq!(
summary.annotation.derivation.unwrap().sources,
vec![source.annotation.entry_id]
);
}
}
+325 -19
View File
@@ -1,7 +1,8 @@
//! Parent-owned registry of direct Internal SubWorker sessions.
//! Parent-owned registry of direct Internal Worker sessions.
//!
//! `SubWorkerSpawn` inserts typed `InternalWorkerSessionHandle`s; List/Send/Stop and
//! worker-observation use the same in-memory authority. Internal children are not persisted, restored, discovered as
//! `SubWorkerSpawn` inserts controllable SubWorker handles, while host services such as
//! compaction insert parent-visible service handles without joining the model-facing
//! List/Send/Stop surface. Internal children are not persisted, restored, discovered as
//! Runtime Workers, or addressed through sockets. Restore consumes any legacy persisted process
//! child records only to reclaim their delegated scope and clear obsolete metadata.
//! Parent registry drop closes all session handles and synchronously returns delegated Write deny
@@ -177,6 +178,52 @@ impl InternalSpawnedWorkerRecord {
}
}
/// Parent-visible service Internal Worker. Unlike a SubWorker this record has no
/// delegated scope, model-facing control name, or stop-summary authority.
#[derive(Clone)]
pub(crate) struct InternalServiceWorkerRecord {
pub service_kind: String,
pub display_name: String,
pub session: InternalWorkerSessionHandle,
protocol_revision: Arc<AtomicU64>,
protocol_emit_lock: Arc<Mutex<()>>,
protocol_terminal: Arc<AtomicBool>,
forwarding_started: Arc<AtomicBool>,
}
impl InternalServiceWorkerRecord {
pub(crate) fn new(
service_kind: impl Into<String>,
display_name: impl Into<String>,
session: InternalWorkerSessionHandle,
) -> Self {
Self {
service_kind: service_kind.into(),
display_name: display_name.into(),
session,
protocol_revision: Arc::new(AtomicU64::new(0)),
protocol_emit_lock: Arc::new(Mutex::new(())),
protocol_terminal: Arc::new(AtomicBool::new(false)),
forwarding_started: Arc::new(AtomicBool::new(false)),
}
}
fn protocol_ref(&self, parent_session_id: Option<String>) -> InternalWorkerRef {
InternalWorkerRef {
session_id: self.session.session_id_string(),
name: self.display_name.clone(),
parent_session_id,
kind: InternalWorkerKind::Service {
kind: self.service_kind.clone(),
},
}
}
fn protocol_revision(&self) -> u64 {
self.protocol_revision.load(Ordering::Acquire)
}
}
pub(crate) struct InternalSpawnReservation {
registry: Arc<SpawnedWorkerRegistry>,
worker_name: String,
@@ -214,6 +261,7 @@ impl Drop for InternalSpawnReservation {
pub struct SpawnedWorkerRegistry {
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
service_records: std::sync::Mutex<Vec<InternalServiceWorkerRecord>>,
internal_names: std::sync::Mutex<HashSet<String>>,
parent_scope: Option<SharedScope>,
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
@@ -226,10 +274,21 @@ pub struct SpawnedWorkerRegistryLoad {
}
impl SpawnedWorkerRegistry {
pub(crate) fn new_for_internal_services() -> Arc<Self> {
Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()),
parent_scope: None,
parent_protocol: Mutex::new(None),
})
}
/// Empty registry used by tests and non-spawning projections.
pub fn new(_runtime_dir: Arc<RuntimeDir>) -> Arc<Self> {
Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()),
parent_scope: None,
parent_protocol: Mutex::new(None),
@@ -239,6 +298,7 @@ impl SpawnedWorkerRegistry {
pub(crate) fn new_internal(_parent_name: String, parent_scope: SharedScope) -> Arc<Self> {
Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()),
parent_scope: Some(parent_scope),
parent_protocol: Mutex::new(None),
@@ -317,6 +377,7 @@ impl SpawnedWorkerRegistry {
Ok(SpawnedWorkerRegistryLoad {
registry: Arc::new(Self {
internal_records: std::sync::Mutex::new(Vec::new()),
service_records: std::sync::Mutex::new(Vec::new()),
internal_names: std::sync::Mutex::new(HashSet::new()),
parent_scope,
parent_protocol: Mutex::new(None),
@@ -356,6 +417,144 @@ impl SpawnedWorkerRegistry {
for record in self.internal_records.lock().unwrap().clone() {
self.start_protocol_forwarding(record);
}
for record in self.service_records.lock().unwrap().clone() {
self.start_service_protocol_forwarding(record);
}
}
/// Register a parent-visible service Internal Worker before its first turn.
pub(crate) fn attach_service(
&self,
record: InternalServiceWorkerRecord,
) -> io::Result<InternalWorkerRef> {
let parent_session_id = self
.parent_protocol
.lock()
.unwrap()
.as_ref()
.map(|(_, id)| id.clone());
let worker_ref = record.protocol_ref(parent_session_id);
let session_id = record.session.session_id_string();
let mut records = self
.service_records
.lock()
.map_err(|_| io::Error::other("internal service-worker registry lock poisoned"))?;
if records
.iter()
.any(|candidate| candidate.session.session_id_string() == session_id)
{
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"internal service Worker is already registered",
));
}
records.push(record.clone());
drop(records);
self.start_service_protocol_forwarding(record);
Ok(worker_ref)
}
/// Stop and remove one parent-owned service Worker. This is host-only and is
/// intentionally separate from the SubWorker control surface.
pub(crate) async fn stop_service(&self, session_id: &str) -> io::Result<bool> {
let record = self
.service_records
.lock()
.map_err(|_| io::Error::other("internal service-worker registry lock poisoned"))?
.iter()
.find(|record| record.session.session_id_string() == session_id)
.cloned();
let Some(record) = record else {
return Ok(false);
};
record
.session
.stop()
.await
.map_err(|error| io::Error::other(error.to_string()))?;
self.remove_service(session_id)
}
/// Remove one service Worker and emit the terminal projection fence.
pub(crate) fn remove_service(&self, session_id: &str) -> io::Result<bool> {
let removed = {
let mut records = self
.service_records
.lock()
.map_err(|_| io::Error::other("internal service-worker registry lock poisoned"))?;
records
.iter()
.position(|record| record.session.session_id_string() == session_id)
.map(|index| records.remove(index))
};
if let Some(record) = removed {
self.publish_service_removal(&record);
Ok(true)
} else {
Ok(false)
}
}
fn start_service_protocol_forwarding(&self, record: InternalServiceWorkerRecord) {
if record.session.visibility() != InternalWorkerVisibility::ParentClient
|| record.forwarding_started.swap(true, Ordering::AcqRel)
{
return;
}
let Some((parent_tx, parent_session_id)) = self.parent_protocol.lock().unwrap().clone()
else {
record.forwarding_started.store(false, Ordering::Release);
return;
};
let worker = record.protocol_ref(Some(parent_session_id));
let protocol_revision = record.protocol_revision.clone();
let protocol_emit_lock = record.protocol_emit_lock.clone();
let protocol_terminal = record.protocol_terminal.clone();
let mut child_rx = record.session.subscribe_events();
tokio::spawn(async move {
loop {
match child_rx.recv().await {
Ok(event) => {
let shutdown = matches!(event, Event::Shutdown);
let _emit_guard = protocol_emit_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
if protocol_terminal.load(Ordering::Acquire) {
break;
}
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let _ = parent_tx.send(Event::InternalWorker {
worker: worker.clone(),
revision,
event: Box::new(event),
});
if shutdown {
break;
}
}
Err(broadcast::error::RecvError::Lagged(skipped)) => {
let _emit_guard = protocol_emit_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
if protocol_terminal.load(Ordering::Acquire) {
break;
}
let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let _ = parent_tx.send(Event::InternalWorker {
worker: worker.clone(),
revision,
event: Box::new(Event::Error {
code: protocol::ErrorCode::Internal,
message: format!(
"internal Worker output lagged by {skipped} events; reconnect to resynchronize"
),
}),
});
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
}
fn start_protocol_forwarding(&self, record: InternalSpawnedWorkerRecord) {
@@ -427,28 +626,37 @@ impl SpawnedWorkerRegistry {
.unwrap()
.as_ref()
.map(|(_, id)| id.clone());
self.internal_records
let mut snapshots = self
.internal_records
.lock()
.unwrap()
.iter()
.filter(|record| record.session.visibility() == InternalWorkerVisibility::ParentClient)
.map(|record| {
let snapshot = record.session.protocol_snapshot();
InternalWorkerSnapshot {
worker: record.protocol_ref(parent_session_id.clone()),
revision: record.protocol_revision(),
entries: snapshot
.entries
.into_iter()
.filter_map(|entry| serde_json::to_value(entry).ok())
.collect(),
status: snapshot.status,
error: snapshot.error,
in_flight: snapshot.in_flight,
internal_workers: snapshot.internal_workers,
}
internal_worker_snapshot(
record.protocol_ref(parent_session_id.clone()),
record.protocol_revision(),
&record.session,
)
})
.collect()
.collect::<Vec<_>>();
snapshots.extend(
self.service_records
.lock()
.unwrap()
.iter()
.filter(|record| {
record.session.visibility() == InternalWorkerVisibility::ParentClient
})
.map(|record| {
internal_worker_snapshot(
record.protocol_ref(parent_session_id.clone()),
record.protocol_revision(),
&record.session,
)
}),
);
snapshots
}
pub(crate) fn get_internal(&self, worker_name: &str) -> Option<InternalSpawnedWorkerRecord> {
@@ -567,6 +775,47 @@ impl SpawnedWorkerRegistry {
revision,
});
}
fn publish_service_removal(&self, record: &InternalServiceWorkerRecord) {
if record.session.visibility() != InternalWorkerVisibility::ParentClient {
return;
}
let Some((parent_tx, parent_session_id)) = self.parent_protocol.lock().unwrap().clone()
else {
return;
};
let _emit_guard = record
.protocol_emit_lock
.lock()
.unwrap_or_else(|error| error.into_inner());
record.protocol_terminal.store(true, Ordering::Release);
let revision = record.protocol_revision.fetch_add(1, Ordering::AcqRel) + 1;
let _ = parent_tx.send(Event::InternalWorkerRemoved {
worker: record.protocol_ref(Some(parent_session_id)),
revision,
});
}
}
fn internal_worker_snapshot(
worker: InternalWorkerRef,
revision: u64,
session: &InternalWorkerSessionHandle,
) -> InternalWorkerSnapshot {
let snapshot = session.protocol_snapshot();
InternalWorkerSnapshot {
worker,
revision,
entries: snapshot
.entries
.into_iter()
.filter_map(|entry| serde_json::to_value(entry).ok())
.collect(),
status: snapshot.status,
error: snapshot.error,
in_flight: snapshot.in_flight,
internal_workers: snapshot.internal_workers,
}
}
impl Drop for SpawnedWorkerRegistry {
@@ -813,6 +1062,63 @@ mod tests {
assert_eq!(snapshots[0].in_flight.blocks.len(), 1);
}
#[tokio::test]
async fn service_worker_is_parent_visible_but_not_subworker_controllable() {
let registry = registry();
let (parent_tx, mut parent_rx) = broadcast::channel(16);
registry.attach_parent_protocol(parent_tx, "parent-session".into());
let (session, child_tx) =
test_internal_worker_session(InternalWorkerVisibility::ParentClient);
let session_id = session.session_id_string();
let worker_ref = registry
.attach_service(InternalServiceWorkerRecord::new(
"compaction",
"Compaction",
session,
))
.unwrap();
assert!(matches!(
worker_ref.kind,
InternalWorkerKind::Service { ref kind } if kind == "compaction"
));
assert!(registry.list_internal().is_empty());
child_tx
.send(Event::TextDone {
text: "summary candidate".into(),
})
.unwrap();
let event = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
.await
.unwrap()
.unwrap();
assert!(matches!(
event,
Event::InternalWorker { worker, revision: 1, event }
if worker.session_id == session_id
&& matches!(worker.kind, InternalWorkerKind::Service { ref kind } if kind == "compaction")
&& matches!(*event, Event::TextDone { ref text } if text == "summary candidate")
));
let snapshots = registry.internal_worker_snapshots();
assert_eq!(snapshots.len(), 1);
assert!(matches!(
snapshots[0].worker.kind,
InternalWorkerKind::Service { ref kind } if kind == "compaction"
));
assert!(registry.remove_service(&session_id).unwrap());
let removed = tokio::time::timeout(Duration::from_secs(1), parent_rx.recv())
.await
.unwrap()
.unwrap();
assert!(matches!(
removed,
Event::InternalWorkerRemoved { worker, revision: 2 }
if worker.session_id == session_id
));
assert!(registry.internal_worker_snapshots().is_empty());
}
#[tokio::test]
async fn service_private_internal_output_is_never_disclosed() {
let registry = registry();
+9 -6
View File
@@ -499,7 +499,10 @@ impl Tool for SubWorkerSpawnTool {
InternalWorkerVisibility::ParentClient,
Some(child_registry.clone()),
Some(Arc::new(move |status| {
if status == InternalWorkerSessionStatus::Failed {
if matches!(
status,
InternalWorkerSessionStatus::Failed | InternalWorkerSessionStatus::Stopped
) {
if let Some(registry) = registry.upgrade() {
if let Err(error) = registry.reclaim_internal_scope(&child_name) {
tracing::warn!(
@@ -1249,7 +1252,7 @@ extract_threshold = 4000
)
.await
.unwrap();
assert!(first_capture.items.iter().any(|item| {
assert!(first_capture.entries.iter().map(|entry| &entry.item).any(|item| {
matches!(item, Item::Message { role: Role::Assistant, content, .. } if content.iter().any(|part| matches!(part, ContentPart::Text { text } if text.contains("reviewed"))))
}));
@@ -1271,7 +1274,7 @@ extract_threshold = 4000
)
.await
.unwrap();
assert!(latest_capture.items.len() > first_capture.items.len());
assert!(latest_capture.entries.len() > first_capture.entries.len());
fail_requests.store(true, Ordering::SeqCst);
send.execute(
@@ -1282,16 +1285,16 @@ extract_threshold = 4000
.unwrap();
assert_eq!(
record.session.wait_until_idle().await,
InternalWorkerSessionStatus::Failed
InternalWorkerSessionStatus::Stopped
);
assert_eq!(calls.load(Ordering::SeqCst), 3);
assert!(
spawner_scope.snapshot().is_writable(&workspace_root),
"Failed terminal child must release its delegated Workdir session"
"Stopped terminal child must release its delegated Workdir session"
);
assert!(
!record.workdir_delegation.is_active(),
"failed child must revoke cloned scoped sessions"
"stopped child must revoke cloned scoped sessions"
);
assert!(registry.get_internal("reviewer-child").is_some());
+1633 -394
View File
File diff suppressed because it is too large Load Diff
+99 -30
View File
@@ -163,7 +163,8 @@ async fn make_worker_with_manifest(
let scope = worker::Scope::writable(&pwd).unwrap();
std::mem::forget(pwd_tmp);
let worker = Engine::new(client);
let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new(
manifest,
worker,
@@ -204,28 +205,34 @@ fn system_texts_in_sink_session_start(
) -> Vec<String> {
let (entries, _rx) = worker.sink().subscribe_with_snapshot();
for entry in entries.into_iter().rev() {
if let session_store::LogEntry::SegmentStart { history, .. } = entry {
return history
let history = match entry {
session_store::LogEntry::AnnotatedSegmentStart { history, .. } => history
.into_iter()
.filter_map(|logged| {
let item: Item = logged.into();
match item {
Item::Message {
role: agen::Role::System,
content,
..
} => Some(
content
.iter()
.map(|p| p.as_text().to_owned())
.collect::<Vec<_>>()
.join(""),
),
_ => None,
}
})
.collect();
}
.map(|entry| entry.item)
.collect::<Vec<_>>(),
session_store::LogEntry::SegmentStart { history, .. } => history,
_ => continue,
};
return history
.into_iter()
.filter_map(|logged| {
let item: Item = logged.into();
match item {
Item::Message {
role: agen::Role::System,
content,
..
} => Some(
content
.iter()
.map(|p| p.as_text().to_owned())
.collect::<Vec<_>>()
.join(""),
),
_ => None,
}
})
.collect();
}
Vec::new()
}
@@ -337,7 +344,12 @@ permission = "write"
// New segment records forked_from pointing at the source.
let new_entries = store.read_all(session_id, new_segment_id).unwrap();
match &new_entries[0] {
LogEntry::SegmentStart {
LogEntry::AnnotatedSegmentStart {
session_id: seg_session,
forked_from: Some(origin),
..
}
| LogEntry::SegmentStart {
session_id: seg_session,
forked_from: Some(origin),
..
@@ -431,7 +443,7 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
let kinds: Vec<&str> = events
.iter()
.map(|e| match e {
Event::CompactStart => "start",
Event::CompactStart { .. } => "start",
Event::CompactDone { .. } => "done",
Event::CompactFailed { .. } => "failed",
_ => "other",
@@ -445,10 +457,61 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
!kinds.contains(&"failed"),
"unexpected CompactFailed in {kinds:?}"
);
let starts = events
.iter()
.filter_map(|event| match event {
Event::CompactStart { lifecycle } => Some(lifecycle),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
starts.len(),
2,
"start and Internal Worker binding revisions"
);
assert_eq!(starts[0].compaction_id, starts[1].compaction_id);
assert_eq!(starts[0].revision, 1);
assert!(starts[0].internal_worker.is_none());
assert_eq!(starts[1].revision, 2);
assert!(matches!(
starts[1].internal_worker.as_ref().map(|worker| &worker.kind),
Some(protocol::InternalWorkerKind::Service { kind }) if kind == "compaction"
));
assert!(events.iter().any(|event| matches!(
event,
Event::InternalWorker { worker, .. }
if matches!(&worker.kind, protocol::InternalWorkerKind::Service { kind } if kind == "compaction")
)), "compactor activity must be projected through the parent stream");
let completed = events
.iter()
.find_map(|event| match event {
Event::CompactDone { lifecycle } => Some(lifecycle),
_ => None,
})
.expect("completed lifecycle");
assert_eq!(completed.compaction_id, starts[0].compaction_id);
assert_eq!(completed.revision, 3);
assert_eq!(completed.summary.as_deref(), Some("summary"));
assert_eq!(completed.state, protocol::CompactionLifecycleState::Done);
let done_index = events
.iter()
.position(|event| matches!(event, Event::CompactDone { .. }))
.expect("done event");
let removed_index = events
.iter()
.position(|event| matches!(event, Event::InternalWorkerRemoved { .. }))
.expect("terminal compactor session must be released");
assert!(
done_index < removed_index,
"terminal lifecycle precedes release fence"
);
// CompactDone carries the new Segment ID; the Session ID is unchanged.
let new_id_in_event = events.iter().find_map(|e| match e {
Event::CompactDone { new_segment_id } => Some(*new_segment_id),
Event::CompactDone { lifecycle } => lifecycle
.new_segment_id
.as_deref()
.and_then(|value| uuid::Uuid::parse_str(value).ok()),
_ => None,
});
assert!(new_id_in_event.is_some(), "CompactDone missing");
@@ -488,7 +551,7 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
let kinds: Vec<&str> = events
.iter()
.map(|e| match e {
Event::CompactStart => "start",
Event::CompactStart { .. } => "start",
Event::CompactDone { .. } => "done",
Event::CompactFailed { .. } => "failed",
_ => "other",
@@ -504,7 +567,10 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
);
let new_id_in_event = events.iter().find_map(|e| match e {
Event::CompactDone { new_segment_id } => Some(*new_segment_id),
Event::CompactDone { lifecycle } => lifecycle
.new_segment_id
.as_deref()
.and_then(|value| uuid::Uuid::parse_str(value).ok()),
_ => None,
});
assert_eq!(new_id_in_event, Some(worker.segment_id()));
@@ -662,7 +728,7 @@ async fn pre_run_compact_failure_broadcasts_start_and_failed() {
let kinds: Vec<&str> = events
.iter()
.map(|e| match e {
Event::CompactStart => "start",
Event::CompactStart { .. } => "start",
Event::CompactDone { .. } => "done",
Event::CompactFailed { .. } => "failed",
_ => "other",
@@ -823,11 +889,14 @@ async fn controller_compact_method_emits_start_and_done() {
.expect("timeout waiting for compact events")
.expect("event")
{
Event::CompactStart => saw_start = true,
Event::CompactStart { .. } => saw_start = true,
Event::CompactDone { .. } => {
break;
}
Event::CompactFailed { error } => panic!("manual compact failed: {error}"),
Event::CompactFailed { lifecycle } => panic!(
"manual compact failed: {}",
lifecycle.error.as_deref().unwrap_or("unknown error")
),
_ => {}
}
}
+74 -31
View File
@@ -32,16 +32,29 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
let mut items = Vec::new();
for entry in entries {
match entry {
LogEntry::AnnotatedSegmentStart { history, .. } => {
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
}
LogEntry::SegmentStart { history, .. } => {
items.extend(history.into_iter().map(Item::from));
}
LogEntry::AnnotatedUserInput { history, .. } => {
items.extend(history.into_iter().map(|entry| Item::from(entry.item)));
}
LogEntry::UserInput { segments, .. } => {
let text = protocol::Segment::flatten_to_text(&segments);
items.push(Item::user_message(text));
}
LogEntry::AnnotatedAssistantItem { entry, .. }
| LogEntry::AnnotatedToolResult { entry, .. } => {
items.push(Item::from(entry.item));
}
LogEntry::AssistantItem { item, .. } | LogEntry::ToolResult { item, .. } => {
items.push(Item::from(item));
}
LogEntry::AnnotatedSystemItem { entry, .. } => {
items.push(entry.item.to_history_item());
}
LogEntry::SystemItem { item, .. } => {
items.push(item.to_history_item());
}
@@ -51,6 +64,14 @@ fn history_from_sink(handle: &WorkerHandle) -> Vec<Item> {
items
}
fn system_item(entry: &LogEntry) -> Option<&session_store::SystemItem> {
match entry {
LogEntry::AnnotatedSystemItem { entry, .. } => Some(&entry.item),
LogEntry::SystemItem { item, .. } => Some(item),
_ => None,
}
}
// ---------------------------------------------------------------------------
// Mock LLM Client
// ---------------------------------------------------------------------------
@@ -192,7 +213,8 @@ async fn make_worker_with_pwd_and_manifest(
let scope = manifest::Scope::writable(&pwd).unwrap();
std::mem::forget(pwd_tmp);
let worker = Engine::new(client);
let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let authority = WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone());
let worker = Worker::new(
manifest,
@@ -784,13 +806,30 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
let client = MockClient::sequential(vec![MockResponse::Hang(simple_text_events())]);
let worker = make_worker(client).await;
let handle = spawn_controller(worker).await;
let mut events = handle.subscribe();
handle
.send(Method::run_text("hello in-flight"))
.await
.unwrap();
wait_for_status(&handle, WorkerStatus::Running).await;
tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
if matches!(
events.recv().await,
Ok(Event::Status {
status: WorkerStatus::Running,
})
) {
break;
}
}
})
.await
.expect("running status event");
// The Running event is the in-flight visibility fence: the committed
// annotated input must already be available to an immediately attaching
// subscriber rather than racing behind this status transition.
let stream = tokio::net::UnixStream::connect(handle.runtime_dir.socket_path())
.await
.unwrap();
@@ -804,10 +843,12 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
// Walk the entries, find a `LogEntry::UserInput` and
// confirm its segments flatten to our submitted text.
let mut found = false;
for value in entries {
for value in &entries {
let entry: session_store::LogEntry =
serde_json::from_value(value).expect("LogEntry deserialise");
if let session_store::LogEntry::UserInput { segments, .. } = entry {
serde_json::from_value(value.clone()).expect("LogEntry deserialise");
if let session_store::LogEntry::UserInput { segments, .. }
| session_store::LogEntry::AnnotatedUserInput { segments, .. } = entry
{
let text = protocol::Segment::flatten_to_text(&segments);
if text == "hello in-flight" {
found = true;
@@ -815,7 +856,10 @@ async fn snapshot_includes_user_input_for_in_flight_turn() {
}
}
}
assert!(found, "snapshot must carry the in-flight UserInput entry");
assert!(
found,
"snapshot must carry the in-flight UserInput entry: {entries:?}"
);
return;
}
Event::Alert(_) => continue,
@@ -1086,7 +1130,7 @@ async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() {
_ => {}
},
entry = entry_rx.recv() => match entry {
Ok(session_store::LogEntry::UserInput { segments, .. }) => {
Ok(session_store::LogEntry::UserInput { segments, .. } | session_store::LogEntry::AnnotatedUserInput { segments, .. }) => {
user_input_segments = Some(segments);
if saw_turn_end {
break;
@@ -1317,11 +1361,8 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
let (entries, _) = handle.sink.subscribe_with_snapshot();
let saw_notify_in_mirror = entries.iter().any(|e| {
matches!(
e,
session_store::LogEntry::SystemItem {
item: session_store::SystemItem::Notification { message, .. },
..
} if message == "turn finished"
system_item(e),
Some(session_store::SystemItem::Notification { message, .. }) if message == "turn finished"
)
});
assert!(
@@ -1463,14 +1504,11 @@ async fn worker_event_turn_ended_while_idle_auto_starts_turn_and_injects_system_
let (entries, _) = handle.sink.subscribe_with_snapshot();
let saw_worker_event_in_mirror = entries.iter().any(|e| {
matches!(
e,
session_store::LogEntry::SystemItem {
item: session_store::SystemItem::WorkerEvent {
event: protocol::WorkerEvent::TurnEnded { worker_name },
..
},
system_item(e),
Some(session_store::SystemItem::WorkerEvent {
event: protocol::WorkerEvent::TurnEnded { worker_name },
..
} if worker_name == "child"
}) if worker_name == "child"
)
});
assert!(
@@ -1552,14 +1590,11 @@ async fn worker_event_scope_sub_delegated_while_idle_stays_control_plane_only()
let (entries, _) = handle.sink.subscribe_with_snapshot();
let saw_scope_event_in_mirror = entries.iter().any(|entry| {
matches!(
entry,
session_store::LogEntry::SystemItem {
item: session_store::SystemItem::WorkerEvent {
event: protocol::WorkerEvent::ScopeSubDelegated { .. },
..
},
system_item(entry),
Some(session_store::SystemItem::WorkerEvent {
event: protocol::WorkerEvent::ScopeSubDelegated { .. },
..
}
})
)
});
assert!(
@@ -2134,9 +2169,13 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() {
for item in items {
match item {
agen::Item::ToolResult {
call_id, summary, ..
call_id,
summary,
disposition,
..
} if call_id == "call_orphan" => {
assert_eq!(summary, "[Interrupted by user]");
assert_eq!(summary, "Tool execution outcome unknown");
assert_eq!(*disposition, agen::ToolResultDisposition::OutcomeUnknown);
saw_synthetic_tool_result = true;
}
agen::Item::Message { role, content, .. } if *role == agen::Role::System => {
@@ -2327,8 +2366,11 @@ async fn paused_cancel_abandons_resume_and_next_input_is_fresh_run() {
assert!(
items.iter().any(|item| matches!(
item,
agen::Item::ToolResult { call_id, summary, .. }
if call_id == "call_cancelled" && summary == "[Interrupted by user]"
agen::Item::ToolResult {
call_id,
disposition: agen::ToolResultDisposition::OutcomeUnknown,
..
} if call_id == "call_cancelled"
)),
"paused cancel should close orphan tool_use before future requests: {items:?}"
);
@@ -2373,7 +2415,8 @@ async fn snapshot_contains_user_input(handle: &WorkerHandle, needle: &str) -> bo
let entry: session_store::LogEntry =
serde_json::from_value(value).expect("LogEntry deserialise");
match entry {
session_store::LogEntry::UserInput { segments, .. } => {
session_store::LogEntry::UserInput { segments, .. }
| session_store::LogEntry::AnnotatedUserInput { segments, .. } => {
protocol::Segment::flatten_to_text(&segments).contains(needle)
}
_ => false,
+6 -3
View File
@@ -188,7 +188,8 @@ async fn make_worker(
let pwd = pwd_tmp.path().to_path_buf();
let scope = worker::Scope::writable(&pwd).unwrap();
let mut worker = Engine::new(client);
let mut worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
worker.register_tool(big_content_tool_definition(tool_name));
let worker = Worker::new(
@@ -460,7 +461,8 @@ async fn metric_write_failure_emits_warn_alert_and_does_not_abort_run() {
// protected token budget covers the only user message). That is enough to drive
// the failure path: at least one metric attempts to write.
let client = MockClient::new(vec![text_response_with_cache("hi", 0, 0)]);
let worker = Engine::new(client);
let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new(
manifest,
worker,
@@ -536,7 +538,8 @@ permission = "write"
let pwd_tmp = tempfile::tempdir().unwrap();
let pwd = pwd_tmp.path().to_path_buf();
let scope = worker::Scope::writable(&pwd).unwrap();
let worker = Engine::new(client);
let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new(
manifest,
worker,
@@ -130,7 +130,8 @@ async fn make_worker_with_body(
EffectivePromptCatalog::new(templates, 1, "test-schema", "test-toolchain").unwrap();
let loader = PromptCatalogSource::builtins_only().with_effective_catalog(projection);
let worker = Engine::new(client);
let worker =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
let mut worker = Worker::new(
manifest,
worker,
+110
View File
@@ -357,6 +357,116 @@ pub struct UpdateWorkspaceMemorySettingsRequest {
pub language: String,
}
/// Public metadata for one Workspace-scoped Repository SSH credential.
///
/// Secret references and secret material are deliberately not part of this DTO.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RepositorySshCredential {
pub credential_id: String,
pub workspace_id: String,
pub name: String,
pub public_key_algorithm: String,
pub public_key_fingerprint: String,
pub current_revision: u64,
pub status: String,
pub created_at: String,
pub rotated_at: Option<String>,
#[serde(default)]
pub referenced_repositories: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CreateRepositorySshCredentialRequest {
pub operation_id: String,
pub credential_id: String,
pub name: String,
pub private_key: String,
#[serde(default)]
pub passphrase: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RotateRepositorySshCredentialRequest {
pub operation_id: String,
pub expected_revision: u64,
pub private_key: String,
#[serde(default)]
pub passphrase: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DeleteRepositorySshCredentialRequest {
pub operation_id: String,
pub expected_revision: u64,
}
/// Public metadata for an explicitly pinned SSH host key.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RepositorySshHostTrust {
pub host_trust_id: String,
pub workspace_id: String,
pub hostname: String,
pub port: u16,
pub key_algorithm: String,
pub host_key: String,
pub fingerprint: String,
pub current_revision: u64,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub referenced_repositories: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PutRepositorySshHostTrustRequest {
pub operation_id: String,
pub host_trust_id: String,
pub hostname: String,
pub port: u16,
pub host_key: String,
#[serde(default)]
pub expected_revision: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct DeleteRepositorySshHostTrustRequest {
pub operation_id: String,
pub expected_revision: u64,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RepositoryAccessMode {
ReadOnly,
ReadWrite,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RepositorySshAccessBinding {
pub repository_id: String,
pub credential_id: String,
pub host_trust_id: String,
pub access: RepositoryAccessMode,
}
/// Secret-free active Repository access projection consumed by later Runtime work.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RepositoryAccessProjection {
pub workspace_id: String,
pub config_revision: u64,
pub projection_digest: String,
pub bindings: Vec<RepositorySshAccessBinding>,
}
#[cfg(test)]
mod tests {
use super::*;
+3
View File
@@ -25,11 +25,13 @@ manifest.workspace = true
protocol = { workspace = true }
project-record.workspace = true
reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] }
ring.workspace = true
rusqlite.workspace = true
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
serde_yaml.workspace = true
sha2.workspace = true
ssh-key.workspace = true
thiserror.workspace = true
ticket.workspace = true
memory.workspace = true
@@ -46,6 +48,7 @@ tracing.workspace = true
ts-rs = { version = "12.0.1", optional = true }
url.workspace = true
uuid = { workspace = true, features = ["v7"] }
zeroize.workspace = true
webauthn-rs = { workspace = true }
[dev-dependencies]
+46 -11
View File
@@ -22,7 +22,7 @@ use crate::records::{
TicketEvidenceEvent, TicketEvidenceSummary, TicketListPageRequest, TicketMergeRequestSummary,
TicketQueryItem, TicketQueryRequest, TicketQueryResponse, TicketRelationView,
TicketRoleAssignmentSummary, TicketShowRequest, TicketSummary, TicketSummaryPage,
summarize_body, truncate_body, validate_project_id,
summarize_body, truncate_body,
};
use crate::store::{
ControlPlaneStore, MemoryDocumentRecord, MemoryStagingRecord, MemoryStagingResolutionRecord,
@@ -633,7 +633,15 @@ impl SqliteWorkspaceAuthority {
predicates.push(format!("o.updated_at<{value}"));
}
if let Some(value) = &query.linked_ticket_id {
let value = bind(SqlValue::Text(value.clone()));
let resolved = self
.store
.resolve_resource_reference(
&self.workspace_id,
WorkspaceResourceKind::Ticket,
value,
)?
.ok_or_else(|| invalid_objective_error("linked Ticket was not found"))?;
let value = bind(SqlValue::Text(resolved));
predicates.push(format!("EXISTS (SELECT 1 FROM objective_ticket_links link WHERE link.workspace_id=o.workspace_id AND link.objective_id=o.objective_id AND link.ticket_id={value})"));
}
let relevance_rank = if let Some(text) =
@@ -1237,6 +1245,10 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
.into_iter()
.map(|link| link.ticket_id)
.collect::<Vec<_>>();
let linked_ticket_keys = linked_tickets
.iter()
.map(|ticket_id| self.resource_key(WorkspaceResourceKind::Ticket, ticket_id))
.collect::<Result<Vec<_>>>()?;
let body_md = record.body_md.clone();
let objective = ObjectiveSummary {
resource_key: self
@@ -1253,6 +1265,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
items.push(objective_query_item(
objective,
linked_tickets,
linked_ticket_keys,
query.query.as_deref(),
&body_md,
));
@@ -1339,9 +1352,19 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
fn create_objective(&self, input: ObjectiveCreateInput) -> Result<ObjectiveDetail> {
validate_objective_title(&input.title)?;
validate_objective_state(&input.state)?;
for ticket_id in &input.linked_tickets {
validate_project_id(ticket_id)?;
}
let linked_tickets = input
.linked_tickets
.iter()
.map(|ticket_reference| {
self.store
.resolve_resource_reference(
&self.workspace_id,
WorkspaceResourceKind::Ticket,
ticket_reference,
)?
.ok_or_else(|| invalid_objective_error("linked Ticket was not found"))
})
.collect::<Result<Vec<_>>>()?;
let now = now_rfc3339();
let objective_id = allocate_record_id(
unix_epoch_millis_now().map_err(|err| {
@@ -1367,8 +1390,7 @@ impl ObjectiveAuthority for SqliteWorkspaceAuthority {
updated_at: now.clone(),
};
self.store.upsert_objective(&record)?;
let links = input
.linked_tickets
let links = linked_tickets
.into_iter()
.map(|ticket_id| ObjectiveTicketLinkRecord {
workspace_id: self.workspace_id.clone(),
@@ -2283,12 +2305,18 @@ fn ticket_query_item(
.iter()
.map(|objective| objective.id.clone())
.collect(),
linked_objective_keys: detail
.linked_objectives
.iter()
.map(|objective| objective.resource_key.clone())
.collect(),
relation_count: detail.relations.outgoing.len() + detail.relations.incoming.len(),
blocker_count: detail.relations.blockers.len(),
unresolved_blocker_count: detail.relations.blockers.len(),
unresolved_review_count: usize::from(detail.evidence.unresolved_request_changes),
evidence: detail.evidence.clone(),
merge_request: detail.merge_request.clone(),
current_coder: detail.current_coder.clone(),
}
}
@@ -2399,6 +2427,7 @@ fn ticket_item_after_cursor(
fn objective_query_item(
objective: ObjectiveSummary,
linked_tickets: Vec<String>,
linked_ticket_keys: Vec<String>,
text: Option<&str>,
body_md: &str,
) -> ObjectiveQueryItem {
@@ -2426,6 +2455,7 @@ fn objective_query_item(
snippet,
linked_ticket_count: linked_tickets.len(),
linked_tickets,
linked_ticket_keys,
}
}
@@ -3308,16 +3338,21 @@ VALUES ('workspace-test', 'ticket', 4);
assert!(!objective.revision.is_empty());
assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2");
assert_eq!(objective.linked_ticket_summaries[0].state, "ready");
let linked_ticket_key = objective.linked_ticket_summaries[0].resource_key.clone();
let objective_query = authority
.query_objectives(ObjectiveQueryRequest {
query: Some("Control plane".to_string()),
linked_ticket_id: Some("00000000001J2".to_string()),
linked_ticket_id: Some(linked_ticket_key.clone()),
limit: Some(1),
..ObjectiveQueryRequest::default()
})
.unwrap();
assert_eq!(objective_query.items.len(), 1);
assert_eq!(objective_query.items[0].linked_ticket_count, 1);
assert_eq!(
objective_query.items[0].linked_ticket_keys,
vec![linked_ticket_key]
);
assert_eq!(objective_query.page.limit, 1);
let body_query = authority
.query_objectives(ObjectiveQueryRequest {
@@ -3410,7 +3445,7 @@ VALUES ('workspace-test', 'ticket', 3);
title: "Create Objective".to_string(),
body_md: "Alpha body".to_string(),
state: "active".to_string(),
linked_tickets: vec!["00000000001J2".to_string()],
linked_tickets: vec!["T-1".to_string()],
})
.unwrap();
assert_eq!(created.title, "Create Objective");
@@ -3436,14 +3471,14 @@ VALUES ('workspace-test', 'ticket', 3);
assert_eq!(state.state, "paused");
assert_eq!(
authority
.link_objective_ticket(&created.id, "00000000001J3")
.link_objective_ticket(&created.id, "T-2")
.unwrap()
.linked_tickets,
vec!["00000000001J2", "00000000001J3"]
);
assert_eq!(
authority
.unlink_objective_ticket(&created.id, "00000000001J2")
.unlink_objective_ticket(&created.id, "T-1")
.unwrap()
.linked_tickets,
vec!["00000000001J3"]

Some files were not shown because too many files have changed in this diff Show More