refactor(agents)!: remove the session write lease (#121113)

* refactor(agents): remove session write lease

* refactor(plugin-sdk): deprecate session write lease

* refactor(doctor): remove session lock checks

* test(agents): remove session lease fixtures

* test(agents): align writer rebound assertion

* refactor(infra): remove retired session lock exports

* test(tooling): preserve embedded abort race shard
This commit is contained in:
Peter Steinberger
2026-08-09 15:30:48 -07:00
committed by GitHub
parent 67e653e580
commit ce53f7e82e
139 changed files with 1139 additions and 7467 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
# Distinct OPENCLAW_* names in production source under src, packages, and extensions.
# Ratchet: lower this number when cleanup removes names; never raise it.
512
507
+44 -44
View File
@@ -3,22 +3,22 @@ a592581a61518734a8a410ec9f71069529ee5fbb54848daf650199eb1cb557ca module/account
71522995185b956a0cc4927a472cc8d1153e5e998874bfd9a750513175174713 module/account-id
64fc61ddb3ed2367ad193c8174e5ddf4f563d7456acd44ab3529219a8d0776fb module/account-resolution
4fbb1c87e99399f842a20d75d5e35a4b7064a1b7f02115c23f9a2a7cdcfb57ee module/agent-config-primitives
f989bfd75d607f1405ada99d3b7cf8407119187a850d264c99e8d5bb0d52e0b6 module/agent-harness
58a05367810e535955fdfdcd21b79c97b1f188dbb3d0f87265b1e6c831926bd6 module/agent-harness-runtime
a9c706069df02e5a876bbf3e35ef55e9eb28906ab84f4f099896ca01ffe15ee7 module/agent-harness
a1af686024edde774ff921f36f398b49021e3acfa21c6b2d943aafda44400ae6 module/agent-harness-runtime
762a2c3df44621e9464fdd3ef437be2a878bfa9a7e8ca7670f88ae549e601614 module/agent-media-payload
587c6f9abe54d1a309b9a720c5bab52b69b30e396765a487982f5e587d509f96 module/agent-runtime
c2ed16c7b059e188e15511fb843fa3778e6469165f74d0de35b775c9760b7b37 module/agent-runtime
83c954ae767298961748011282c5f79d5cd7a4187f8769a91e85cad7ee950902 module/agent-scope-runtime
8fecb210e22bce4532b6ab649b09465f0bd2c857a44abf40db7d683d6491e6da module/allow-from
f0cd21c1854e209363fc2cbc684ba9dee9668ba7b5d0b57a96d4c8da825d7337 module/allowlist-config-edit
8b20c8157e04f13233c239401185ae23f52565a7dd337274f954b30e890be6a6 module/approval-auth-runtime
0ae73ab9450fb773515a5247505d18706193fb0670654e3af55f03400ed0a0a8 module/approval-client-runtime
50b21c297db54c391689ba805b5ffd1620e7d0891cae98bed9f6c41929690988 module/approval-delivery-runtime
131f830c475cec1adb2f8a0904b7f750411f87d0c76b110ca528125ee93f52a1 module/approval-auth-runtime
10b7388a85818918433239e11afe82426cd37f2fffb96f7711116e4e7ed22fe5 module/approval-client-runtime
d8872a2e0579c618ab27bfca31581eef9c0d12c6e39f07c951b1921cb57f2ab7 module/approval-delivery-runtime
afba69ce95b3a939511c1f1e6b27a5999e8793304cba7578b0785c7edc342580 module/approval-gateway-runtime
a9f10f7c70287d7dc8ad224ca6424234652e74908ae7de2fc7edf4c98c91de4a module/approval-handler-adapter-runtime
f8b5c31c956b567827d58e9a1f6493846448b3c0ae30904cc7f4d872b8071d26 module/approval-handler-runtime
97f96bde0c23596a1d25e43bb2145d20de9b93b4657ac2ebdf7248be086b05da module/approval-native-runtime
85a5c33e5ce2137b469b4d432274185a49fa5146421e0985a5c837447ef1697c module/approval-native-runtime
b102781a2d78bbfafcf4205aeb2c169ad69bc4010edaf376647036cf3fba48d7 module/approval-reply-runtime
031443b5f1f815eb9c17d1bc808bd8737ae4a0493cca8bd170b7ef2c5c3426dc module/approval-runtime
eff194a7ab6fbdcebb021633645fd5df49b6744f6b1297cc78404f396aea4b14 module/approval-runtime
01ca912836b8dec672f705e294f72d346e778557e4c591317d67558ea7669c0b module/archive
d7e53de63b0ac11a266e4abdc18ba6e9401b80309f5c8f5f6a72a00f65dfe3bd module/boolean-param
076689cd8e62c842580d61a34d97bcf42c90406287d428fc5d0d3eb373ec274d module/channel-actions
@@ -26,46 +26,46 @@ d1341161e2f5d44eaf7eca3a777ab3f106c7db1db32778bf98c75c7fa7d57ca4 module/channel
c2cc71d5070b6071c51248b0648d1ad1a9468d3737df890adc77ec02025e8853 module/channel-config-primitives
9fa7e5f9b3515f0bbc252210b034d39c25de9eb431def30dddce3198c309efac module/channel-config-schema
a6af4971aa36345f1a32d97981bd36cc29794f62f30bd57859df2816c65f6fde module/channel-contract
3ce6bdb43ae6af29630c1a7f5db5401cd3f0a757d8c957ef8bb451eb463d052b module/channel-core
68ad8c35e936c03801b562ecdce87650c0ed45cff02b2c87dd1c952164553984 module/channel-core
40f8e726de245e17cf7bf908bb7d6fb084f4927f5ccc20b0c089f47af1bd7a0e module/channel-dm-policy
87c2a2117a21eaca7e90399a59a7f4177d72061ca47e114b9fbf679e00f5ae39 module/channel-entry-contract
f1013a2efe1df0df5f3c6916bd036e8fdef60edb4d1d9978ced12b27c60f84a8 module/channel-entry-contract
56b185eb99eb4981056befd38cd9511b655923934acb1c22aeec7d9c504bdf96 module/channel-feedback
5cb907889536199b49c587b87b296b37e32dcd0710ee847a460aa2442ee8ec9b module/channel-inbound
1fd4109d3e6fcefb529268236fce17dccf73c1e3ed9f8bbccff40e2a4a008a5c module/channel-inbound
780abefd1600c55da219da43c769c873d071ab616b5e02d22f365677ed5a7be4 module/channel-inbound-debounce
b72485da4fe5d7f73352f4474d00e30b749b64c8a2066755e104958cd96036c2 module/channel-ingress-runtime
f83762680c0d0aa6fa1bad9e53c70bfdf3694126dbdd9491db764a69ed3f6d67 module/channel-lifecycle
0e47457e38d1df0bd572e1408cde2ca6a788b65205f43c585316b5ad3a8f2f16 module/channel-logging
fab761166333914424d58c6cbfd278dbae11ebe85715a107e6885ca3a25831d9 module/channel-message
ee87fc2421c43535e8362a81311c658c87a79820d0b99096bc6076435360132b module/channel-outbound
0fca740b839b297b29e19782feb4347523a8360e9496f90abebd41020fb76149 module/channel-pairing
0cf2c3ab1cb5520099620040215312148211b77dd16d514082caeff52f4f6855 module/channel-plugin-common
a72e82470c1b985275f78a14dc76f6c8b6a1ec3a25a19160ba2653d2f779984d module/channel-message
23944f54612c09665ee4f32b42ae83228b560edb8f795176e66f482d8c5236cd module/channel-outbound
32796d1a1437955d4b20ae474205bda1a5f3e6d87b01d3abe69d48075b83c88f module/channel-pairing
bac1a249dc3b2450b4e8cda1f6782d89a4224587525a48d46361204964b2f4e7 module/channel-plugin-common
c2c7900e47c0595bbc8a166aa60cfbc5a0e02f9a4c0985d5fd630c7dec4c3dca module/channel-policy
e787a315cec9681a49a9e5d1af8fe4fae345561d30996888e5ef6593faae0a75 module/channel-reply-pipeline
1a142544f8c8e2bbf250bc582949473ca1f72c9d7c0b917b8c8bb6af38bc8f7f module/channel-reply-pipeline
482370e60135db9bfaf07f24bab549e5fde09ab265a6061a1f587c5d93929e91 module/channel-runtime-context
32c2e9ea4f954a6b04d6e61b392e63d42b5cc74ccbdc1404c94a4c97463f0ca1 module/channel-secret-basic-runtime
941673d941b3ab9bd553ba9153305af7933019254a504a603907a237d06b5e79 module/channel-secret-runtime
7f5bc35b328c716022a442804f3e83416ee23eaddb49e771c385660dc6540612 module/channel-send-result
32b446e32f9b9b54008e81ac7069869098c14ca6620e5ca700e41a0544d7b614 module/channel-send-result
4493cf54a54d7159e27d33884b1cd231cd54a038a85a3d5d3676e149954444a7 module/channel-setup
66df387345c0d64083904f4271228bb1e5660e40b00f42afc5d802d3ff07627f module/channel-status
198ed5042f86da7731dad61bc66f44bcb249fd9e369d79cf4509df172d5cab7f module/channel-streaming
67df67da5ae72e9eaeb19d41b6bd2432ec4fd8b7b63b2b616fb98f3b4e0ec41d module/channel-streaming-config
fdeffe356c7c4edeec9f8fd03edcadc375eabc7a9412e582b10c3180e3ef40fc module/cli-argv
ad12670dbfe538f8d0ebf4fb2b68080e93a760278278e6b1ce9bb129d4b2d533 module/collection-runtime
8f19b5729eb9e38ade635a3759d307826fb00ea5cf8d4b50343b54511dfe6e01 module/command-auth
7126e3cec538129822aebdb2fba88de11bee5fb689bff3120a8e25bfc697d603 module/command-auth-native
b705cd511e3a1ad8da44b0459ffb57bcf21482dfa6eb0016a6db99dc52cfb89d module/command-auth
3bcb143c3e1de4366db7981f8ba18c7cf809437b049cb6025f750b360da9c851 module/command-auth-native
396f60be14d8fe1144076cffeafc2e2ddc0379b5e664f9fd45d13a0edc4a9f2f module/command-detection
841389ea61d1389d1dbcc9350832e5cb5307ab5b380cbc14800ea62796e06ed7 module/command-primitives-runtime
0ab02df5c3904386bfb062e7dce0a24e2ed835b54dcc0112e367346624cb0efe module/command-status
02011f70a5de5f1861609206038b101d3e9bbf39bf45a3cfaaae56596299225b module/config-contracts
7ee5bc35d30165be33ab229a96dd6a6e3e04c620fa4c41753d568798c514feb3 module/config-mutation
eac10f08466e8513c5b5663b29ca3d4ca76d66858367163c5239f08f33d7024c module/config-runtime
2d4bcc06be68852dd829d47b295e5318a99ccd4db6c8c14bf4742c6b58a517a2 module/config-runtime
069ca892c9d9c0d82f9a0d1e72970da2b1bc5d3c4fc15e127b18bbf78292cd2a module/conversation-runtime
e028a493e9d30afbd1d58ee28539d9dd47d160065ad44ccdb3473badd7a71550 module/core
be2e2ce71478e859d9b6d68d32d31a48c6591d0065545bc45a1c4db8e684b30e module/dedupe-runtime
3b7142dc38efc70b115cfc84d291ba3e848c2d385ae9a62fd93615e9f3c7a561 module/core
dbff5cec0161ca3afcabda96be29b7c9832f37eacc88ea4f5c7389ae6db263f2 module/dedupe-runtime
ebef0e650ab45e44c9335e2b3e15588c968cea6dadd125364a076f9c50ad1e8c module/device-bootstrap
9ecd22bc3acae3f9b0556b305c4232abe54a7993b910caa789cba6cf917dd1e2 module/diagnostic-runtime
6d90412b97efcc675d16acc3aeb2752520a613ce4bb25b87e47f84fd6b36736d module/directory-runtime
c2c20568f733021911438f66efa21acd013f458dd44f33f7df5530084eeddb8d module/discord
aa2d26865800cc400860be02edbcc2fba4f2a7448b44bb10540abf03c197cd15 module/discord
f2d69888d0c799e12d97b5f92393906aa843be25f61cb6a6c55d32f1db800a44 module/error-runtime
1caf8cc5552ca5e392599457e3f9616e033de199d9f47c12320cdee617a6b0e9 module/extension-shared
dd9f6e0fd33cc88b22543c1ee30cc09cf4de4d8f30dff7b7f9cebef885c21543 module/gateway-method-runtime
@@ -75,8 +75,8 @@ f558a90b52bda7e431cc46005f8735263ee065942925f9d547ae528f218be479 module/gateway
53e032e2f3a2f434702ce1633b0d2a0bed5cbe5f6ef01d7dc348967395036b9a module/hook-runtime
4d9f7d3c3e59113b493f3cac812b3c117968035af10a751a92f2aca8e2ec8806 module/inbound-envelope
4928af5d2509f696b896f53ac790303a0742202dbcdae3e44fe6d1b434a9c1ba module/inbound-event-delivery
ad782ab0200a135c6c7d6ce6fd2079bf46c6834adb2d984e527559c1b3ab608c module/inbound-reply-dispatch
97a718b5c0f276c7cb0feab83e5aa2bb0f9a14175236d94d4c9f7a7baeb05137 module/infra-runtime
1f3da2dc90e3e02b81383d367620a9f65ac299ff1933bc908e388e9dd7fa2884 module/inbound-reply-dispatch
660435bdefd6371816fcb1676096a5840ce78347f9788eee3e50c08f492ff5a8 module/infra-runtime
ce73721421f1b903dd04ead4df173582e59ea3e9990248102c448b419cc6d272 module/ingress-effect-once
2fdc37835ccc4651c580847250adf8e42c165483eb239907aea981a6552e24b4 module/interactive-runtime
408d257ab5cc4b88a22b7e7595039cb8fc524b261c44141b294fbd0100ba62ee module/json-store
@@ -85,32 +85,32 @@ e5acf130491ef188b193a3e3dc5284e53a29f7e08278caa188f5ddb96e860dc4 module/logging
f1ca4ced4305d0769c2d8cc1291137ac7002fe0e6eaec2c1a71edad2204c8311 module/matrix
8cef237e683b323062fa4134a88a0b5b8ff97f1c5d356654c8062fdbf5eed87c module/media-local-roots
f74d7295fe716aa140aa0bc9300d6259d71dab826de0808fca6bb02592bf5d6e module/media-mime
d0c103832eac668fbbd1593c1c9e3ee77b4233f4b336a6d94cd2f1b032c3050f module/media-runtime
1cd61c7f1721e2af43cc307c5d9cb03f072bf5666aa1e3906d322a9ec038406d module/media-runtime
6a52f93107335f88751704352cc01e62add06f854a5b7d765e2a5ee87c0313b6 module/media-store
6e5b09a983a69041873ece5bc3b719c0dc913e5a69e2afdf6c7958889e711097 module/media-understanding
7f74e947cca0cf894869bede29815863d5eacca016d04d6262e9d41b3999ae94 module/media-understanding-runtime
9d94b6190c4a8f0fc508c1bb17339d16e943feb03bdb9ada7b25b67c345607a1 module/meeting-runtime
0a63357429e02a239cfcd6f8ccedd50f254fa9f78c4e4fef8a014b8808011d2f module/meeting-runtime
e5d2b540090c17602a1c36b42559f7b516e20730e06bbdd8606597c66ef2ef95 module/memory-core-host-engine-foundation
38d54e0fcd07f475fccfdb3cff87808dfa4a7e54ed6d35cd61cfc81cae11db53 module/memory-host-core
aafaf500f6593e6e907ab970d1bea7aff00dd7499c78dac2ea6f73fc4d7f595d module/memory-host-core
1efa0aadc4261d1c6073058cbf3dcc9fa681424819bdd14333e19b249bbc4b18 module/messaging-targets
577d82ef09e96de6af83a22c9b89cc1ddf2d76964abac5808b102ce04ced294f module/model-session-runtime
4f6572822a931393b83044bf8a5dd792376a8abc603a8bf175a98cdbbdf66390 module/models-provider-runtime
1c30f86bcb65bf462005a90aa78efc170e98563d960cf29184c5253fd6e8a9b8 module/model-session-runtime
dc9dc9645c3a1e1d3bf35538fef0e6ccf6e4df27b8ae937783683f2af16f6495 module/models-provider-runtime
b518b4caa7f138d448fe08f5fc9e5f67da5d4b26aafaee2370feb2300e1baa21 module/native-command-config-runtime
7a5b56c37e5d862feab8258eb9f79f44e6946333c93b0c22dd3f792b33a7c19a module/native-command-registry
15d1d490e4f7909d2d563b83988258a9cc5fa1344cffb0cd3dcd7592c0467632 module/param-readers
ca7a56bb1a6169b4cf9befbf5aa21da280a8086fdc49fca4eec520a7a7c98549 module/persistent-dedupe
f8b250da3eab2f85b2bf358872f910b29e541ea04358498a6deab353fa0520fc module/plugin-config-runtime
7a8cfa7161563aad7450c0a1c43317b4dfec5c3b0807b4c66aa923ded3cf0a1d module/plugin-entry
282ab9d7de884bda645c3b40fd19a30d2061d7efc437226f469fa657ee15d99a module/plugin-runtime
37486f578a06306f9bcbb980245fa032f0a8e18daff4fdb24f0852409304c1a2 module/provider-auth
1b831d62daf65ffcb48de16cad3e065eae0a4318776c26f3cb10c09016b1ac62 module/provider-catalog-runtime
a7a2e4977e8ef784fa2852c047160cc4c3b3b751597ebc74e567bb47ae4de8d1 module/plugin-entry
0cffe85023a15806f23db7bd1c9f255d1a24653620e47d0b790ad99b5d4095c7 module/plugin-runtime
e195c5cc5682b3d37ff64e3fb3895d47ba5cccacf7be46ca22f5634d85dc48a7 module/provider-auth
f1e3c0fa27eb8cc6b7252c30315001347e12709d0587a172e00f230247637f2c module/provider-catalog-runtime
8131147d699394bd06503e2ea2f5f1a50b1594a87dded6d118b74a8d0328c8f6 module/proxy-capture
913c32adcd94ea9c8b7efac0f7c65231500c49129e638c0682eb632da3d5a0c8 module/question-gateway-runtime
f319c52a0f1655de752efa55a983893e56e4afa0727f6cc4896c3238c984e91c module/reply-chunking
2b3caf36f524ef7cac9caecadcc394fc333c8f122ee9c8bb8c99a688d290f2f4 module/reply-dispatch-runtime
9903ca6e9eedc144e3bce91ec74cddeb67a7af109966094cc12c2f1cacdb03c4 module/reply-dispatch-runtime
73f861fa3179d5af1159853c5acab0eec7a6c8f9398dcb75ea770e784fca6727 module/reply-history
ecc6e3190892ff416ce69d26c8ce0aa08a468c8689ca3606c18b0271e2d87597 module/reply-payload
dcef2112bc3a57b70306a800b0420a44e2b02d1f6ca47d13c8508c78b5bccf37 module/reply-runtime
405614f15ef5cde172c08d8f85917a22162ddb4ad6ebffafe91343131028f8cd module/reply-runtime
aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-walk
292405cda407e2569f731212f6f981b12bfe682836d5f6e58c6ae194ba27f144 module/routing
7877a7e58fa32a64107154e5b714c6d165e96989d4aa5f43e0afac085a187af0 module/run-command
@@ -118,20 +118,20 @@ aa07d85d99fdd2b1e0cbe9975fb6dcae66b8bdce2607c6bd5402ae68bb15118c module/root-wa
f7900c5ff5827e1b53a926367d807bd4c81b313ab74fd0409d571ae881226a60 module/runtime-config-snapshot
8e33ccbbe610c6c3ad04a140f7511a16cf406733c8459216b1fb4b1e7f0208d9 module/runtime-env
7e871b7319745678bb83fcfc1b54c8751b0ab1af92ff06c01d0659ac92863c11 module/runtime-group-policy
8e2bef59679a87201f38367f84ee2da8c2c055c6da4444f5ab52f5a7be49e7de module/runtime-store
f03c34763b71ed4a1e512295191d5e0f2cb289fbf8adbfa4b6053977ba7adef6 module/runtime-store
d17862c40825af1ddf0257b44f1e1cbb9c375e8e5ed668fae75d530d1a465cf9 module/secret-file
8e2ac4d3973d8d8ce4478e3440d66ee5c0d9213b0fe9e927c421d14fd31e5e86 module/secret-input
5958846179128ea6b29135fb90ce4e40b8df7de63d1c65d1696e52294cbc0f52 module/secret-input-runtime
72ca4634e0038ad6dc80c4ce12eb34014e1fa990c7eabe19bb4501b2722ee685 module/secret-ref-runtime
6db2af355b0d1d46f493a5d34a63a6ea5231dafbd8b896d976a52da681868f67 module/security-runtime
cfdecb8a5744c5f8acdde768e41a5c370df0a48325c8956f0827db4b446cc1db module/session-catalog
b2dc6360d27155152d4415a78c62dcc91a8a46321f9fbcb02ebb006668bbd0cd module/session-discussion
bbfe5fe057d23fd874689776aff2d9aa506877c683e275ee66fa28fd866fc58e module/session-store-runtime
e0b0dd5f31394d5bba70608a0f31f2738c1a2f338cbf92feabf620ba18f8e835 module/session-catalog
db4b4610a071ae2f2a456f11fc2a03ecf0554b946138b46c51cb7badca7ec173 module/session-discussion
6df070e65e9eda3ffa42894482a20437ae4932aba97c22b469e3ca8eeb9f74fa module/session-store-runtime
e54f5c86a55683028a8c4a7c821cf9b9f11b48f91ae9b08e79f8e94b91eb5fde module/setup
41c4790efda179c600fcc8cc4f2cd9c0505efd86df15a1a840aeb99c839bc627 module/setup-runtime
44d37e0d9131ad2859f41068f2604090c784e65f1bd6ebda8e051b6f2e5e1660 module/setup-tools
9c36f9dd90bee4b1fd51812862d835b54377bfbe4b9b9387d33ed35863eb924b module/skill-commands-runtime
04b995526eb91e230b5334e4f4e119e93423d480d4bd72b9c186abd198cef2ca module/speech-settings
ba8b411439e210f01f95322554e49ce82abcbe25ba968746feeffbca8af285a4 module/skill-commands-runtime
f11732d0be82559c2a234dbf62513a2a9bd12bd20703cfb6258da620f3b9cb05 module/speech-settings
1e4df0f48b50a8eb546459af1e947ba143c287e3d9c13706c45b67d411c46ad5 module/ssrf-policy
e4940099376e18e34c234d4d90d836cbe234c3e891e40b7b4879168b386b1121 module/ssrf-runtime
3c3c812d2d0c997ec81d117346347b802a3445e8e0d76aa6d0e3259f3cf55f98 module/state-paths
@@ -141,11 +141,11 @@ f097d0096b21c8a052f0f649b7512ecf2aba4744ae6956f001950e053828b309 module/string-
aef35bee2502cd6ed8765409b758e452aff8ac9469fd773e6a2a44c9a1bc3f66 module/temp-path
87fa81b9e58d8fc04a4b4202d2d37fca339615f5225687d9db905151439e0f4d module/text-chunking
50d2fa56b2ad57edd12d74201d18ab43045b5c9da0ab98cc158cebe9bd8b768b module/text-runtime
b81beab0301b6d44e366b70376f914d630bd09c1428fdd2cb6ec869f8c2736e3 module/tool-plugin
c850a6ad69309b2a8e279f7c8f44bce67cdf14aeba89ebf5064337a5e41f8816 module/tool-plugin
dc1a073c59ab61e2789533b777b3f0cb9af689d64a97796b10e8aa82552510db module/tool-results
5d4f1dbde1238799e5244b9c0b799aa2fb7d607d0c072d9a70659978825ef4ee module/tool-send
cda105b721d498df23a554c6b68be150b8fe66b8b9172185c31a0b3b0646b1dc module/web-media
3699bee6cba14d964d380e02cb7e17817abaeb4e238e22b080afeac54dbc5065 module/webhook-ingress
020fbccf78933eddcab3175480455b03289bc6ddd4f2e1cd97bbc88186bbc3f7 module/webhook-ingress
ddd05cb74246c393fef4d3fd8331eddd4db72a000fe45e9e515357b757636854 module/webhook-request-guards
de59e86e126b75d13251cba7ebbe27b44d9b5588785d98df5ff4d6722374c81f module/widget-html
9161b36ec0ab062ea41b363c894fcd672a7727f21cb726739f99f9c184fce69d module/zod
-1
View File
@@ -190,7 +190,6 @@ Modernized core doctor checks stay attached to the ordered doctor contribution t
```bash
openclaw doctor --lint --only core/doctor/gateway-config --json
openclaw doctor --lint --skip core/doctor/skills-readiness
openclaw doctor --lint --all --skip core/doctor/session-locks
```
`--only` and `--skip` accept full check ids and may be repeated. If an `--only` id is not registered, no check runs for that id; use `checksRun`/`checksSkipped` in the output to confirm a focused gate selects the checks you expect.
+3 -3
View File
@@ -2,7 +2,7 @@
summary: "Agent loop lifecycle, streams, and wait semantics"
read_when:
- You need an exact walkthrough of the agent loop or lifecycle events
- You are changing session queueing, transcript writes, or session write lock behavior
- You are changing session queueing, writer claims, or transcript write fencing
title: "Agent loop"
---
@@ -27,14 +27,14 @@ execution, streaming, persistence.
Runs are serialized per session key (session lane) and optionally through a global lane, preventing tool/session races. Messaging channels choose a queue mode (steer/followup/collect/interrupt) that feeds this lane system; see [Command Queue](/concepts/queue).
Transcript writes are serialized by the per-session lane and the SQLite writer queue. Each append or rewrite validates the current session identity inside its synchronous commit transaction, so a stale run cannot overwrite a newer session generation.
Before streaming, an admitted run records its durable `activeWriterRunId` claim. Every transcript append or rewrite supplies `expectedWriterRunId`, and the synchronous commit transaction verifies that it still matches the active claim. A superseded run therefore cannot commit stale transcript data. The SQLite writer queue orders per-agent mutations, while the Gateway state-directory lock prevents another Gateway or `openclaw agent --local` process from owning the same state directory concurrently.
## Session and workspace preparation
- Workspace is resolved and created; sandboxed runs may redirect to a sandbox workspace root.
- Skills are loaded (or reused from a snapshot) and injected into env and prompt.
- Bootstrap/context files are resolved and injected into the system prompt.
- A session write lock is acquired and the session transcript target is prepared before streaming starts. Any later transcript rewrite, compaction, or truncation path must take the same lock before mutating the SQLite transcript rows.
- The session transcript target and writer claim are prepared before streaming starts. Later rewrites, compaction, and truncation use the same in-transaction writer-claim fence.
## Prompt assembly
+1
View File
@@ -222,6 +222,7 @@ Use `isLoopbackHost(host)` when a plugin must accept only the local machine. It
| `plugin-sdk/runtime-env` | Narrow runtime env, logger, timeout, retry, and backoff helpers |
| `plugin-sdk/browser-config` | Private-local after July 2026; Supported browser config facade for normalized profile/defaults, CDP URL parsing, and browser-control auth helpers |
| `plugin-sdk/agent-harness-task-runtime` | Private-local after July 2026; Generic task lifecycle and completion delivery helpers for harness-backed agents using a host-issued task scope |
| `plugin-sdk/agent-harness-runtime` | Agent-harness runtime helpers. `acquireSessionWriteLock`, `resolveSessionWriteLockAcquireTimeoutMs`, `resolveSessionWriteLockOptions`, and `SessionWriteLockAcquireTimeoutConfig` are deprecated no-op compatibility exports scheduled for removal in the 2026.10 release train. They no longer block or create lock sidecars; harnesses should rely on OpenClaw's per-session lane plus the durable writer claim and in-transaction fence. |
| `plugin-sdk/codex-mcp-projection` | Private-local after July 2026; Reserved bundled Codex helper for projecting user MCP server config into Codex thread config; not for third-party plugins |
| `plugin-sdk/codex-session-transcript-runtime` | Private-local bundled Codex helper for serializing transcript-mirror writes; not for third-party plugins |
| `plugin-sdk/channel-runtime-context` | Generic channel runtime-context registration and lookup helpers |
@@ -76,11 +76,9 @@ Normal Gateway writes flow through the session accessor, which serializes per-ag
OpenClaw no longer creates automatic `sessions.json.bak.*` rotation backups during Gateway writes. The current schema rejects the legacy `session.maintenance.rotateBytes` key, and `openclaw doctor --fix` removes it from older configs.
Transcript mutations use the session write queue for the SQLite transcript target:
Session write locks use fixed production defaults. The corresponding
`OPENCLAW_SESSION_WRITE_LOCK_*` environment variables remain available for
process-level diagnostics and emergency overrides.
Transcript mutations pass through the session accessor and SQLite writer queue.
Each mutation verifies the active run's durable writer claim inside its commit
transaction, so a superseded run cannot write to the transcript.
### Downgrading After The SQLite Flip
@@ -96,52 +96,6 @@ describe("qa suite runtime agent session helpers", () => {
expect(options?.timeoutMs).toBe(60_000);
});
it("retries transient session store lock timeouts while creating sessions", async () => {
const lockTimeoutError = Object.assign(
new Error("SessionWriteLockTimeoutError: session file locked"),
{ code: "OPENCLAW_SESSION_WRITE_LOCK_TIMEOUT" },
);
gatewayCall
.mockRejectedValueOnce(lockTimeoutError)
.mockResolvedValueOnce({ key: " session-2 " });
vi.useFakeTimers();
const pending = createSession(env, "Retry Session", "agent:qa:retry");
await vi.advanceTimersByTimeAsync(1_000);
await expect(pending).resolves.toBe("session-2");
expect(gatewayCall).toHaveBeenCalledTimes(2);
expect(gatewayCall).toHaveBeenNthCalledWith(
2,
"sessions.create",
{ label: "Retry Session", key: "agent:qa:retry" },
expect.objectContaining({ timeoutMs: expect.any(Number) }),
);
});
it("retries transient session store stale locks while creating sessions", async () => {
const lockStaleError = Object.assign(
new Error("SessionWriteLockStaleError: session file lock stale"),
{ code: "OPENCLAW_SESSION_WRITE_LOCK_STALE" },
);
gatewayCall.mockRejectedValueOnce(lockStaleError).mockResolvedValueOnce({ key: " session-3 " });
vi.useFakeTimers();
const pending = createSession(env, "Retry Stale Session", "agent:qa:stale-retry");
await vi.advanceTimersByTimeAsync(1_000);
await expect(pending).resolves.toBe("session-3");
expect(gatewayCall).toHaveBeenCalledTimes(2);
expect(gatewayCall).toHaveBeenNthCalledWith(
2,
"sessions.create",
{ label: "Retry Stale Session", key: "agent:qa:stale-retry" },
expect.objectContaining({ timeoutMs: expect.any(Number) }),
);
});
it("reads effective tool ids once and drops blanks", async () => {
gatewayCall.mockResolvedValueOnce({
groups: [
@@ -41,7 +41,6 @@ type QaSessionTranscriptSeedParams = {
updatedAt: number;
};
const SESSION_STORE_LOCK_RETRY_DELAYS_MS = [1_000, 3_000, 5_000] as const;
const SESSION_STORE_FTS_SETTLE_RETRY_DELAYS_MS = [100, 250, 500, 1_000, 2_000] as const;
const MAX_COMPACTION_SUMMARIES = 16;
const MAX_SUCCESSFUL_TOOL_CALL_EVENTS = 64;
@@ -69,18 +68,6 @@ type QaSessionTranscriptSummaryOptions = {
allowEmpty?: boolean;
};
function isSessionStoreLockTimeout(error: unknown) {
const text = formatErrorMessage(error);
return (
text.includes("OPENCLAW_SESSION_WRITE_LOCK_TIMEOUT") ||
text.includes("OPENCLAW_SESSION_WRITE_LOCK_STALE") ||
text.includes("SessionWriteLockTimeoutError") ||
text.includes("SessionWriteLockStaleError") ||
text.includes("session file locked") ||
text.includes("session file lock stale")
);
}
function isSessionStoreFtsSettleRace(error: unknown) {
const text = formatErrorMessage(error);
return (
@@ -261,29 +248,8 @@ function emptySessionTranscriptSummary(eventCursor: number): QaSessionTranscript
};
}
async function callGatewayWithSessionStoreLockRetry<T>(
env: QaGatewayCallEnv,
method: string,
params: Record<string, unknown>,
options: { timeoutMs: number },
) {
const retryDelaysMs = SESSION_STORE_LOCK_RETRY_DELAYS_MS;
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt += 1) {
try {
return (await env.gateway.call(method, params, options)) as T;
} catch (error) {
if (!isSessionStoreLockTimeout(error) || attempt === retryDelaysMs.length) {
throw error;
}
await sleep(retryDelaysMs[attempt]);
}
}
throw new Error(`${method} failed after session store lock retries`);
}
async function createSession(env: QaGatewayCallEnv, label: string, key?: string) {
const created = await callGatewayWithSessionStoreLockRetry<{ key?: string }>(
env,
const created = (await env.gateway.call(
"sessions.create",
{
label,
@@ -292,7 +258,7 @@ async function createSession(env: QaGatewayCallEnv, label: string, key?: string)
{
timeoutMs: liveTurnTimeoutMs(env, 60_000),
},
);
)) as { key?: string };
const sessionKey = created.key?.trim();
if (!sessionKey) {
throw new Error("sessions.create returned no key");
@@ -301,10 +267,7 @@ async function createSession(env: QaGatewayCallEnv, label: string, key?: string)
}
async function readEffectiveTools(env: QaGatewayCallEnv, sessionKey: string) {
const payload = await callGatewayWithSessionStoreLockRetry<{
groups?: Array<{ tools?: Array<{ id?: string }> }>;
}>(
env,
const payload = (await env.gateway.call(
"tools.effective",
{
sessionKey,
@@ -312,7 +275,7 @@ async function readEffectiveTools(env: QaGatewayCallEnv, sessionKey: string) {
{
timeoutMs: liveTurnTimeoutMs(env, 90_000),
},
);
)) as { groups?: Array<{ tools?: Array<{ id?: string }> }> };
const ids = new Set<string>();
for (const group of payload.groups ?? []) {
for (const tool of group.tools ?? []) {
@@ -325,10 +288,7 @@ async function readEffectiveTools(env: QaGatewayCallEnv, sessionKey: string) {
}
async function readSkillStatus(env: QaGatewayCallEnv, agentId = "qa") {
const payload = await callGatewayWithSessionStoreLockRetry<{
skills?: QaSkillStatusEntry[];
}>(
env,
const payload = (await env.gateway.call(
"skills.status",
{
agentId,
@@ -336,7 +296,7 @@ async function readSkillStatus(env: QaGatewayCallEnv, agentId = "qa") {
{
timeoutMs: liveTurnTimeoutMs(env, 45_000),
},
);
)) as { skills?: QaSkillStatusEntry[] };
return payload.skills ?? [];
}
+4 -2
View File
@@ -142,7 +142,8 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"text-runtime": 192,
"agent-runtime": 2,
"channel-secret-runtime": 23,
"agent-harness-runtime": 4,
// +4: session-write lease no-op compatibility stubs through the 2026.10 train.
"agent-harness-runtime": 8,
"agent-config-primitives": 2,
"command-auth": 78,
discord: 47,
@@ -318,7 +319,8 @@ export function readPluginSdkSurfaceBudgets(env: NodeJS.ProcessEnv = process.env
// +1: shared ingress error factory projected through channel-message.
// +1: shared ingress retention defaults projected through channel-message.
// +1: shipped channel setup state-migration declaration during its migration window.
1704,
// +4: session-write lease no-op compatibility stubs through the 2026.10 train.
1708,
env,
),
publicWildcardReexports: readPluginSdkSurfaceBudgetEnv(
@@ -348,9 +348,6 @@ const providerAuthAliasMocks = vi.hoisted(() => ({
},
),
}));
const sessionWriteLockMocks = vi.hoisted(() => ({
acquireSessionWriteLock: vi.fn(),
}));
vi.mock("../cli-runner.js", () => ({
runCliAgent: runCliAgentMock,
}));
@@ -407,17 +404,6 @@ vi.mock("../embedded-agent.js", () => ({
runEmbeddedAgent: runEmbeddedAgentMock,
}));
vi.mock("../session-write-lock.js", async () => {
const actual = await vi.importActual<typeof import("../session-write-lock.js")>(
"../session-write-lock.js",
);
sessionWriteLockMocks.acquireSessionWriteLock.mockImplementation(actual.acquireSessionWriteLock);
return {
...actual,
acquireSessionWriteLock: sessionWriteLockMocks.acquireSessionWriteLock,
};
});
function makeCliResult(text: string): EmbeddedAgentRunResult {
return {
payloads: [{ text }],
@@ -665,7 +651,6 @@ describe("CLI attempt execution", () => {
hasClaudeLiveSessionForOwnerMock.mockReturnValue(false);
providerAuthAliasMocks.resolveProviderAuthAliasMap.mockClear();
providerAuthAliasMocks.resolveProviderIdForAuth.mockClear();
sessionWriteLockMocks.acquireSessionWriteLock.mockClear();
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolvePluginSetupRegistry: () => ({ cliBackends: [] }) as never,
@@ -2208,36 +2193,6 @@ describe("CLI attempt execution", () => {
});
});
it("holds the session-key lease for the embedded assistant gap-fill", async () => {
const sessionKey = "agent:main:direct:gap-fill-lease";
const sessionEntry = makeSessionEntry("session-gap-fill-lease");
await writeSessionStoreSeed({ [sessionKey]: sessionEntry });
const release = vi.fn();
sessionWriteLockMocks.acquireSessionWriteLock.mockResolvedValueOnce({ release });
await persistCliTurnTranscript({
body: "ignored prompt",
result: makeCliResult("runtime answer"),
sessionId: sessionEntry.sessionId,
sessionKey,
sessionEntry,
storePath,
sessionAgentId: "main",
sessionCwd: tmpDir,
config: {},
embeddedAssistantGapFill: true,
});
expect(sessionWriteLockMocks.acquireSessionWriteLock).toHaveBeenCalledOnce();
expect(sessionWriteLockMocks.acquireSessionWriteLock).toHaveBeenCalledWith(
expect.objectContaining({
sessionFile: expect.any(String),
targetKind: "session-key",
}),
);
expect(release).toHaveBeenCalledOnce();
});
it("persists a media-only ACP user turn when the reply is empty", async () => {
const sessionKey = "agent:main:direct:acp-media-only";
const sessionFile = path.join(tmpDir, "session-acp-media-only.jsonl");
+2 -38
View File
@@ -22,7 +22,6 @@ import { messageToolOwnsVisibleReply } from "../../auto-reply/source-reply-deliv
import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js";
import { resolveSessionAuthProfileOverrideSource } from "../../config/sessions/auth-profile-override-provenance.js";
import { persistSessionTranscriptTurn } from "../../config/sessions/session-accessor.js";
import { acquireOwnedSessionTranscriptWriteLock } from "../../config/sessions/transcript-write-context.js";
import { readTailAssistantTextFromSessionTranscript } from "../../config/sessions/transcript.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -83,17 +82,12 @@ import { resolveCliRuntimeExecutionProvider } from "../model-runtime-aliases.js"
import { isCliProvider } from "../model-selection.js";
import { resolveOpenAIRuntimeProvider } from "../openai-routing.js";
import { hasVerifiedRequesterCompletionHandoff } from "../requester-tool-policy.js";
import { resolveAgentRunSessionTarget, type AgentRunSessionTarget } from "../run-session-target.js";
import type { AgentRunSessionTarget } from "../run-session-target.js";
import { resolveAgentRunAbortLifecycleFields } from "../run-termination.js";
import { buildAgentRuntimeAuthPlan } from "../runtime-plan/auth.js";
import type { AgentMessage } from "../runtime/index.js";
import { resolveSandboxRuntimeStatus } from "../sandbox/runtime-status.js";
import { withLocalSessionPlacementTurnAdmission } from "../session-placement-admission.js";
import {
acquireSessionWriteLock,
resolveSessionWriteLockOptions,
resolveSessionWriteLockTargetKey,
} from "../session-write-lock.js";
import { buildUsageWithNoCost } from "../stream-message-shared.js";
import {
isSubagentAnnounceCompletionHandoff,
@@ -520,37 +514,7 @@ export async function persistCliTurnTranscript(params: {
return await persist();
}
const sessionTarget = await resolveAgentRunSessionTarget({
agentId: params.sessionAgentId,
config: params.config,
missingSessionKey: "resolve-existing",
sessionFile: params.sessionFile,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionTarget: {
agentId: params.sessionAgentId,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
...(params.storePath ? { storePath: params.storePath } : {}),
...(params.threadId !== undefined ? { threadId: params.threadId } : {}),
},
});
const sessionLock =
(await acquireOwnedSessionTranscriptWriteLock({
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
sessionTarget,
})) ??
(await acquireSessionWriteLock({
sessionFile: resolveSessionWriteLockTargetKey(sessionTarget),
targetKind: "session-key",
...resolveSessionWriteLockOptions(params.config),
}));
try {
return await persist();
} finally {
await sessionLock.release();
}
return await persist();
}
export function runAgentAttempt(params: {
+1 -1
View File
@@ -361,7 +361,7 @@ function stripErrorPrefix(raw: string): string {
return raw.replace(/^error:\s*/i, "").trim();
}
export function inferSignalStatus(signal: FailoverSignal): number | undefined {
function inferSignalStatus(signal: FailoverSignal): number | undefined {
if (typeof signal.status === "number" && Number.isFinite(signal.status)) {
return signal.status;
}
@@ -47,9 +47,6 @@ export const hookRunner = {
runAfterCompaction: vi.fn(async () => undefined),
};
export const acquireSessionWriteLockMock = vi.fn(async (_params?: unknown) => ({
release: vi.fn(async () => {}),
}));
export const resolveContextEngineMock = vi.fn(async () => ({
info: { ownsCompaction: true as boolean },
compact: contextEngineCompactMock,
@@ -566,7 +563,6 @@ export function resetCompactHooksHarnessMocks(): void {
hookRunner.runAfterCompaction.mockResolvedValue(undefined);
acquireAgentRunPreparedModelRuntimeMock.mockClear();
acquireSessionWriteLockMock.mockClear();
resolveContextEngineMock.mockReset();
resolveContextEngineMock.mockResolvedValue({
@@ -638,7 +634,6 @@ export async function loadCompactHooksHarness(): Promise<{
compactEmbeddedAgentSessionDirect: typeof import("./compact.js").compactEmbeddedAgentSessionDirect;
compactEmbeddedAgentSession: typeof import("./compact.queued.js").compactEmbeddedAgentSession;
testing: typeof import("./compact.js").testing;
withOwnedSessionTranscriptWrites: typeof import("../../config/sessions/transcript-write-context.js").withOwnedSessionTranscriptWrites;
onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate;
onInternalSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onInternalSessionTranscriptUpdate;
}> {
@@ -795,23 +790,6 @@ export async function loadCompactHooksHarness(): Promise<{
resolveSandboxContext: resolveSandboxContextMock,
}));
vi.doMock("../session-write-lock.js", async () => {
const { resolveSessionWriteLockTargetKey } = await vi.importActual<
typeof import("../session-write-lock.js")
>("../session-write-lock.js");
return {
acquireSessionWriteLock: acquireSessionWriteLockMock,
resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 0),
resolveSessionWriteLockAcquireTimeoutMs: vi.fn(() => 60_000),
resolveSessionWriteLockOptions: vi.fn(() => ({
timeoutMs: 60_000,
staleMs: 1_800_000,
maxHoldMs: 300_000,
})),
resolveSessionWriteLockTargetKey,
};
});
vi.doMock("../../context-engine/init.js", () => ({
ensureContextEnginesInitialized: vi.fn(),
}));
@@ -1099,20 +1077,17 @@ export async function loadCompactHooksHarness(): Promise<{
};
});
const [compactModule, compactQueuedModule, transcriptEvents, transcriptWriteContext] =
await Promise.all([
import("./compact.js"),
import("./compact.queued.js"),
import("../../sessions/transcript-events.js"),
import("../../config/sessions/transcript-write-context.js"),
]);
const [compactModule, compactQueuedModule, transcriptEvents] = await Promise.all([
import("./compact.js"),
import("./compact.queued.js"),
import("../../sessions/transcript-events.js"),
]);
return {
...compactModule,
compactEmbeddedAgentSession: compactQueuedModule.compactEmbeddedAgentSession,
onSessionTranscriptUpdate: transcriptEvents.onSessionTranscriptUpdate,
onInternalSessionTranscriptUpdate: transcriptEvents.onInternalSessionTranscriptUpdate,
withOwnedSessionTranscriptWrites: transcriptWriteContext.withOwnedSessionTranscriptWrites,
};
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
@@ -11,7 +11,6 @@ import { createReplyOperation } from "../../auto-reply/reply/reply-run-registry.
import { upsertSessionEntry } from "../../config/sessions/session-accessor.js";
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
import {
acquireSessionWriteLockMock,
acquireAgentRunPreparedModelRuntimeMock,
applyExtraParamsToAgentMock,
applyAgentCompactionSettingsFromConfigMock,
@@ -72,7 +71,6 @@ let compactEmbeddedAgentSession: typeof import("./compact.queued.js").compactEmb
let compactTesting: typeof import("./compact.js").testing;
let onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate;
let onInternalSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onInternalSessionTranscriptUpdate;
let withOwnedSessionTranscriptWrites: typeof import("../../config/sessions/transcript-write-context.js").withOwnedSessionTranscriptWrites;
const TEST_SESSION_ID = "session-1";
const TEST_SESSION_KEY = "agent:main:session-1";
@@ -308,7 +306,6 @@ beforeAll(async () => {
compactTesting = loaded.testing;
onSessionTranscriptUpdate = loaded.onSessionTranscriptUpdate;
onInternalSessionTranscriptUpdate = loaded.onInternalSessionTranscriptUpdate;
withOwnedSessionTranscriptWrites = loaded.withOwnedSessionTranscriptWrites;
});
beforeEach(() => {
@@ -332,41 +329,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
resetCompactSessionStateMocks();
});
it("acquires the normal session lock without process-wide reentry", async () => {
const result = await compactEmbeddedAgentSessionDirect(wrappedCompactionArgs());
expect(result).toMatchObject({ ok: true, compacted: true });
const lockOptions = mockCallArg(acquireSessionWriteLockMock);
expect(lockOptions).toMatchObject({
sessionFile: expect.any(String),
targetKind: "session-key",
});
expect(lockOptions.sessionFile).not.toBe(TEST_SESSION_KEY);
expect(lockOptions).not.toHaveProperty("allowReentrant");
});
it("reuses the matching logical writer lock during direct compaction", async () => {
const withSessionWriteLockCall = vi.fn();
const withSessionWriteLock = async <T>(run: () => Promise<T> | T): Promise<T> => {
withSessionWriteLockCall();
return await run();
};
const result = await withOwnedSessionTranscriptWrites(
{
sessionKey: TEST_SESSION_KEY,
sessionTarget: wrappedCompactionArgs().sessionTarget,
assertOwned: () => undefined,
withSessionWriteLock,
},
async () => await compactEmbeddedAgentSessionDirect(wrappedCompactionArgs()),
);
expect(result).toMatchObject({ ok: true, compacted: true });
expect(withSessionWriteLockCall).toHaveBeenCalledOnce();
expect(acquireSessionWriteLockMock).not.toHaveBeenCalled();
});
it("fails closed before generic compaction for a model-locked native session", async () => {
const result = await compactEmbeddedAgentSessionDirect({
sessionId: "session-1",
@@ -1,5 +1,4 @@
import { formatSqliteSessionFileMarker } from "../../config/sessions/legacy-sqlite-marker.js";
import { acquireOwnedSessionTranscriptWriteLock } from "../../config/sessions/transcript-write-context.js";
/**
* Executes compaction while owning the transcript lock, session lifecycle,
* hooks, checkpoint, and optional successor transcript rotation.
@@ -24,12 +23,6 @@ import { pickFallbackThinkingLevel } from "../embedded-agent-helpers.js";
import { resolveAgentRunSessionTarget } from "../run-session-target.js";
import { guardSessionManager } from "../session-tool-result-guard-wrapper.js";
import { sanitizeToolUseResultPairing } from "../session-transcript-repair.js";
import {
acquireSessionWriteLock,
resolveSessionLockMaxHoldFromTimeout,
resolveSessionWriteLockTargetKey,
resolveSessionWriteLockOptions,
} from "../session-write-lock.js";
import { agentSessionAutomaticCompaction } from "../sessions/agent-session-compaction.js";
import { createAgentSession, estimateTokens, SessionManager } from "../sessions/index.js";
import { getModelRegistryRuntime } from "../sessions/model-registry-runtime.js";
@@ -119,21 +112,6 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti
sessionKey: params.sessionKey,
sessionTarget: params.sessionTarget,
});
const sessionLock =
(await acquireOwnedSessionTranscriptWriteLock({
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
sessionTarget,
})) ??
(await acquireSessionWriteLock({
sessionFile: resolveSessionWriteLockTargetKey(sessionTarget),
targetKind: "session-key",
...resolveSessionWriteLockOptions(params.config, {
maxHoldMsFallback: resolveSessionLockMaxHoldFromTimeout({
timeoutMs: compactionTimeoutMs,
}),
}),
}));
try {
const transcriptPolicy = runtimePlan.transcript.resolvePolicy(runtimePlanModelContext);
const sessionManager = guardSessionManager(SessionManager.open(sessionTarget), {
@@ -566,7 +544,6 @@ export async function executePreparedCompactionSession(runtime: PreparedCompacti
}
} finally {
await runtime.disposeToolRuntimes();
await sessionLock.release();
}
} catch (err) {
const reason = resolveCompactionFailureReason({
@@ -4,10 +4,10 @@ import { expectDefined } from "@openclaw/normalization-core";
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it, vi } from "vitest";
import { SessionTranscriptWriterClaimReboundError } from "../../config/sessions/transcript-write-context.js";
import { isSecretValueRegisteredForRedaction } from "../../logging/secret-redaction-registry.js";
import { mintSecretSentinel, resolveSecretSentinel } from "../../secrets/sentinel.js";
import { prepareGooglePromptCacheStreamFn } from "./google-prompt-cache.js";
import { EmbeddedAttemptSessionTakeoverError } from "./run/attempt.session-lock.js";
type SessionCustomEntry = {
type: "custom";
@@ -488,9 +488,9 @@ describe("google prompt cache", () => {
expect(getCapturedPayload()?.cachedContent).toBe("cachedContents/system-cache-2");
});
it("propagates session takeover errors from cache entry persistence", async () => {
it("propagates writer-claim rebound from cache entry persistence", async () => {
const now = 2_500_000;
const takeoverError = new EmbeddedAttemptSessionTakeoverError("/tmp/session.jsonl");
const takeoverError = new SessionTranscriptWriterClaimReboundError("agent:main:test");
const sessionManager = {
appendCustomEntry: vi.fn(async () => {
throw takeoverError;
@@ -14,6 +14,7 @@ import {
resolveExpiresAtMsFromDurationMs,
} from "@openclaw/normalization-core/number-coercion";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { SessionTranscriptWriterClaimReboundError } from "../../config/sessions/transcript-write-context.js";
import { parseGeminiAuth } from "../../infra/gemini-auth.js";
import { normalizeGoogleApiBaseUrl } from "../../infra/google-api-base-url.js";
import { cancelUnreadResponseBody, readResponseWithLimit } from "../../infra/http-body.js";
@@ -28,10 +29,8 @@ import {
import { resolveProviderRequestHeaders } from "../provider-request-config.js";
import { buildGuardedModelFetch } from "../provider-transport-fetch.js";
import type { StreamFn } from "../runtime/index.js";
import { isSessionWriteLockAcquireError } from "../session-write-lock-error.js";
import { log } from "./logger.js";
import { isGooglePromptCacheEligible, resolveCacheRetention } from "./prompt-cache-retention.js";
import { EmbeddedAttemptSessionTakeoverError } from "./run/attempt.session-lock.js";
const GOOGLE_PROMPT_CACHE_CUSTOM_TYPE = "openclaw.google-prompt-cache";
// CachedContent metadata responses are tiny (name + expireTime); cap the read so
@@ -196,7 +195,7 @@ async function appendGooglePromptCacheEntry(
try {
await sessionManager.appendCustomEntry(GOOGLE_PROMPT_CACHE_CUSTOM_TYPE, entry);
} catch (err) {
if (err instanceof EmbeddedAttemptSessionTakeoverError || isSessionWriteLockAcquireError(err)) {
if (err instanceof SessionTranscriptWriterClaimReboundError) {
throw err;
}
// ignore persistence failures
@@ -1,4 +1,4 @@
// Coverage for external cancellation, timeout, and session-lock release paths.
// Coverage for external cancellation and timeout paths.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { EmbeddedAgentQueueHandle } from "../runs.js";
import {
@@ -8,8 +8,6 @@ import {
} from "./attempt-abort.js";
import { createEmbeddedAttemptSessionSettleTracker } from "./attempt-session-settle.js";
import { prepareEmbeddedAttemptTimeout } from "./attempt-timeout-prepare.js";
import { createEmbeddedAttemptSessionLockController } from "./attempt.session-lock.js";
import { SESSIONS_YIELD_ABORT_REASON } from "./attempt.sessions-yield.js";
const mocks = vi.hoisted(() => ({
countActiveToolExecutions: vi.fn(() => 0),
@@ -153,7 +151,6 @@ describe("createEmbeddedAttemptExternalAbortController", () => {
const state = createAbortState();
const session = createTrackedSessionAbort();
const onAttemptTimeout = vi.fn();
const releaseHeldLockForAbort = vi.fn(async () => {});
const attempt = {
abortSignal: source.signal,
onAttemptTimeout,
@@ -178,7 +175,6 @@ describe("createEmbeddedAttemptExternalAbortController", () => {
isProbeSession: true,
log: { warn: vi.fn() },
runAbortController,
sessionLockController: { releaseHeldLockForAbort },
state: state.port,
});
controller.setRunAbort(abortRun);
@@ -210,7 +206,6 @@ describe("createEmbeddedAttemptExternalAbortController", () => {
expect(onAttemptTimeout).toHaveBeenCalledOnce();
expect(session.abort).toHaveBeenCalledExactlyOnceWith(reason);
expect(mocks.markActiveEmbeddedRunAbandoned).toHaveBeenCalledOnce();
expect(releaseHeldLockForAbort).toHaveBeenCalledOnce();
} finally {
timeout.clearTimers();
controller.dispose();
@@ -241,42 +236,13 @@ describe("createEmbeddedAttemptExternalAbortController", () => {
});
describe("createEmbeddedAttemptRunAbort", () => {
it("releases session ownership non-terminally for sessions_yield handoff", async () => {
const releaseHeldLockForAbort = vi.fn(async () => {});
const abortRun = createEmbeddedAttemptRunAbort({
abortActiveSession: vi.fn(async () => {}),
activeSession: { abortCompaction: vi.fn(), isCompacting: false },
attempt: {
runId: "run-yield",
sessionFile: "agent:main:main",
sessionId: "session-yield",
sessionKey: "agent:main:main",
},
getQueueHandle: () => undefined,
isProbeSession: false,
log: { warn: vi.fn() },
runAbortController: new AbortController(),
sessionLockController: { releaseHeldLockForAbort },
state: createAbortState().port,
});
abortRun(false, SESSIONS_YIELD_ABORT_REASON);
await vi.waitFor(() => {
expect(releaseHeldLockForAbort).toHaveBeenCalledWith({
reason: SESSIONS_YIELD_ABORT_REASON,
terminal: false,
});
});
});
it("settles timeout state, session work, queue ownership, and the lock", async () => {
it("settles timeout state, session work, and queue ownership", async () => {
const state = createAbortState();
const timeoutReason = new Error("attempt deadline");
timeoutReason.name = "TimeoutError";
const abortCompaction = vi.fn();
const abortActiveSession = vi.fn(async () => {});
const onAttemptTimeout = vi.fn();
const releaseHeldLockForAbort = vi.fn(async () => {});
const queueHandle = {} as EmbeddedAgentQueueHandle;
const runAbortController = new AbortController();
mocks.countActiveToolExecutions.mockReturnValue(1);
@@ -294,7 +260,6 @@ describe("createEmbeddedAttemptRunAbort", () => {
isProbeSession: false,
log: { warn: vi.fn() },
runAbortController,
sessionLockController: { releaseHeldLockForAbort },
state: state.port,
});
@@ -315,60 +280,15 @@ describe("createEmbeddedAttemptRunAbort", () => {
sessionFile: "/tmp/session.jsonl",
reason: "timeout",
});
expect(releaseHeldLockForAbort).toHaveBeenCalledTimes(1);
expect(releaseHeldLockForAbort).toHaveBeenCalledWith({
reason: timeoutReason,
terminal: true,
});
});
it("preserves a timeout reason through the abort path to a late prompt handoff", async () => {
const sessionLockController = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
const timeoutReason = new Error("cron setup timed out");
timeoutReason.name = "TimeoutError";
const abortRun = createEmbeddedAttemptRunAbort({
abortActiveSession: vi.fn(async () => {}),
activeSession: { abortCompaction: vi.fn(), isCompacting: false },
attempt: {
onAttemptTimeout: vi.fn(),
runId: "run-timeout-handoff",
sessionFile: "agent:main:main",
sessionId: "session-timeout-handoff",
sessionKey: "agent:main:main",
},
getQueueHandle: () => undefined,
isProbeSession: false,
log: { warn: vi.fn() },
runAbortController: new AbortController(),
sessionLockController,
state: createAbortState().port,
});
abortRun(true, timeoutReason);
await expect(sessionLockController.releaseForPrompt()).rejects.toBe(timeoutReason);
await sessionLockController.dispose();
});
it("logs lock release failures without replacing the manual abort reason", async () => {
// Abort cleanup must not replace the original timeout/manual-abort reason
// with a secondary lock-release failure.
const state = createAbortState();
it("preserves a manual abort reason", () => {
const abortReason = new Error("manual abort");
const releaseError = new Error("locked");
const releaseHeldLockForAbort = vi.fn(async () => {
throw releaseError;
});
const warn = vi.fn();
const runAbortController = new AbortController();
const abortRun = createEmbeddedAttemptRunAbort({
abortActiveSession: vi.fn(async () => {}),
activeSession: { abortCompaction: vi.fn(), isCompacting: false },
attempt: {
onAttemptTimeout: vi.fn(),
runId: "run-manual",
sessionFile: "/tmp/session.jsonl",
sessionId: "session-manual",
@@ -376,21 +296,13 @@ describe("createEmbeddedAttemptRunAbort", () => {
},
getQueueHandle: () => undefined,
isProbeSession: false,
log: { warn },
log: { warn: vi.fn() },
runAbortController,
sessionLockController: { releaseHeldLockForAbort },
state: state.port,
state: createAbortState().port,
});
abortRun(false, abortReason);
await Promise.resolve();
await Promise.resolve();
expect(runAbortController.signal.reason).toBe(abortReason);
expect(releaseHeldLockForAbort).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
"failed to release session lock on abort: runId=run-manual Error: locked",
);
});
});
@@ -5,12 +5,10 @@ import { countActiveToolExecutions } from "../../embedded-agent-subscribe.handle
import { isSignalTimeoutReason } from "../../failover-error.js";
import type { AgentSession } from "../../sessions/index.js";
import { markActiveEmbeddedRunAbandoned, type EmbeddedAgentQueueHandle } from "../runs.js";
import type { EmbeddedAttemptSessionLockController } from "./attempt.session-lock.js";
import { isSessionsYieldAbortError, isSessionsYieldAbortReason } from "./attempt.sessions-yield.js";
import { shouldFlagCompactionTimeout } from "./compaction-timeout.js";
import type { EmbeddedRunAttemptParams } from "./types.js";
type AbortLockReleaseLog = {
type AbortLog = {
warn(message: string): void;
};
@@ -162,9 +160,8 @@ export function createEmbeddedAttemptRunAbort(input: {
>;
getQueueHandle: () => EmbeddedAgentQueueHandle | undefined;
isProbeSession: boolean;
log: AbortLockReleaseLog;
log: AbortLog;
runAbortController: AbortController;
sessionLockController: Pick<EmbeddedAttemptSessionLockController, "releaseHeldLockForAbort">;
state: Pick<
EmbeddedAttemptAbortStatePort,
| "markAborted"
@@ -197,7 +194,6 @@ export function createEmbeddedAttemptRunAbort(input: {
}
abortAccepted = true;
input.state.markAborted();
let effectiveReason = reason;
if (isTimeout) {
input.state.markTimedOut();
if (
@@ -207,7 +203,6 @@ export function createEmbeddedAttemptRunAbort(input: {
input.state.markTimedOutDuringToolExecution();
}
const timeoutReason = reason instanceof Error ? reason : createTimeoutAbortReason();
effectiveReason = timeoutReason;
input.attempt.onAttemptTimeout?.(timeoutReason);
input.runAbortController.abort(timeoutReason);
} else {
@@ -225,36 +220,5 @@ export function createEmbeddedAttemptRunAbort(input: {
reason: "timeout",
});
}
releaseEmbeddedAttemptSessionLockForAbort({
sessionLockController: input.sessionLockController,
log: input.log,
runId: input.attempt.runId,
abortKind: isTimeout ? "timeout abort" : "abort",
reason: effectiveReason,
terminal:
isTimeout || (!isSessionsYieldAbortError(reason) && !isSessionsYieldAbortReason(reason)),
});
};
}
/**
* Releases the held session lock after an abort without blocking abort
* propagation. Release failures are logged because the caller is already
* unwinding the run and cannot safely await lock cleanup there.
*/
function releaseEmbeddedAttemptSessionLockForAbort(params: {
sessionLockController: Pick<EmbeddedAttemptSessionLockController, "releaseHeldLockForAbort">;
log: AbortLockReleaseLog;
runId: string;
abortKind: "abort" | "timeout abort";
reason?: unknown;
terminal: boolean;
}): void {
void params.sessionLockController
.releaseHeldLockForAbort({ reason: params.reason, terminal: params.terminal })
.catch((err: unknown) => {
params.log.warn(
`failed to release session lock on ${params.abortKind}: runId=${params.runId} ${String(err)}`,
);
});
}
@@ -23,26 +23,21 @@ import {
type buildContextEnginePromptCacheInfo,
} from "./attempt.context-engine-helpers.js";
import { buildAfterTurnRuntimeContextFromUsage } from "./attempt.prompt-helpers.js";
import type { createEmbeddedAttemptSessionLockController } from "./attempt.session-lock.js";
import { shouldPersistCompletedBootstrapTurn } from "./attempt.thread-helpers.js";
import type { EmbeddedRunAttemptParams } from "./types.js";
type CacheTrace = ReturnType<typeof createCacheTrace>;
type AnthropicPayloadLogger = ReturnType<typeof createAnthropicPayloadLogger>;
type HookRunner = ReturnType<typeof getGlobalHookRunner>;
type AttemptSessionLockController = Awaited<
ReturnType<typeof createEmbeddedAttemptSessionLockController>
>;
type PromptCacheInfo = ReturnType<typeof buildContextEnginePromptCacheInfo>;
type WithOwnedSessionWriteLock = <T>(operation: () => Promise<T> | T) => Promise<T>;
type WithOwnedTranscriptWrite = <T>(operation: () => Promise<T> | T) => Promise<T>;
type CompleteEmbeddedAttemptAfterTurnInput = {
attempt: EmbeddedRunAttemptParams;
activeContextEngine?: ContextEngine;
activeSession: AgentSession;
sessionManager: SessionManager;
sessionLockController: AttemptSessionLockController;
withOwnedSessionWriteLock: WithOwnedSessionWriteLock;
withOwnedTranscriptWrite: WithOwnedTranscriptWrite;
state: {
promptError: unknown;
yieldAborted: boolean;
@@ -102,7 +97,7 @@ export async function completeEmbeddedAttemptAfterTurn(
messagesSnapshot: AgentMessage[];
prePromptMessageCount: number;
sessionManager?: SessionManager;
withSessionManagerRewriteLock: WithOwnedSessionWriteLock;
withSessionManagerRewriteLock: WithOwnedTranscriptWrite;
}) => {
await finalizeAttemptContextEngineTurn({
contextEngine: activeContextEngine,
@@ -179,13 +174,13 @@ export async function completeEmbeddedAttemptAfterTurn(
prePromptMessageCount:
state.contextEngineAfterTurnCheckpoint ?? state.prePromptMessageCount,
sessionManager,
withSessionManagerRewriteLock: input.withOwnedSessionWriteLock,
withSessionManagerRewriteLock: input.withOwnedTranscriptWrite,
});
}
}
if (!state.beforeAgentFinalizeRevisionReason) {
await input.withOwnedSessionWriteLock(async () => {
await input.withOwnedTranscriptWrite(async () => {
const lifecycleState = input.readLifecycleState();
if (
shouldPersistCompletedBootstrapTurn({
@@ -40,7 +40,7 @@ function createInput(hooks: PluginHookRegistration[] = []) {
agent: { state },
};
const lock = { acquisitions: 0 };
const withOwnedSessionWriteLock = async <T>(operation: () => Promise<T> | T): Promise<T> => {
const withOwnedTranscriptWrite = async <T>(operation: () => Promise<T> | T): Promise<T> => {
lock.acquisitions += 1;
return await operation();
};
@@ -59,7 +59,7 @@ function createInput(hooks: PluginHookRegistration[] = []) {
modelPrompt: "model prompt",
sessionManager,
systemPrompt: "system prompt",
withOwnedSessionWriteLock,
withOwnedTranscriptWrite,
},
lock,
sessionManager,
@@ -14,7 +14,7 @@ type HookRunner = NonNullable<ReturnType<typeof getGlobalHookRunner>>;
type BeforeAgentRunHookRunner = Pick<HookRunner, "hasHooks" | "runBeforeAgentRun">;
type HookContext = Parameters<HookRunner["runBeforeAgentRun"]>[1];
type AttemptSessionManager = Parameters<typeof flushSessionManagerTranscript>[0];
type WithOwnedSessionWriteLock = <T>(operation: () => Promise<T> | T) => Promise<T>;
type WithOwnedTranscriptWrite = <T>(operation: () => Promise<T> | T) => Promise<T>;
type BeforeAgentRunSession = {
messages: AgentMessage[];
@@ -38,7 +38,7 @@ export async function runEmbeddedAttemptBeforeAgentRun(input: {
modelPrompt: string;
sessionManager: AttemptSessionManager;
systemPrompt: string;
withOwnedSessionWriteLock: WithOwnedSessionWriteLock;
withOwnedTranscriptWrite: WithOwnedTranscriptWrite;
}): Promise<BeforeAgentRunBlockOutcome | undefined> {
if (!input.hookRunner?.hasHooks("before_agent_run")) {
return undefined;
@@ -66,7 +66,7 @@ export async function runEmbeddedAttemptBeforeAgentRun(input: {
},
};
try {
await input.withOwnedSessionWriteLock(() => {
await input.withOwnedTranscriptWrite(() => {
input.sessionManager.appendMessage(
redactedUserMessage as Parameters<typeof input.sessionManager.appendMessage>[0],
);
@@ -89,8 +89,7 @@ function createFixture() {
sessionLock: {
compactionTimeoutMs: 1_000,
ownedTranscriptWriteContext: {},
sessionLockController: {},
withOwnedSessionWriteLock: vi.fn(),
withOwnedTranscriptWrite: vi.fn(),
},
setup: {
effectiveFsWorkspaceOnly: false,
@@ -53,7 +53,6 @@ export async function runEmbeddedAttemptExecutionPhase(
attempt,
activeSession,
sessionManager,
sessionLockController: input.sessionLock.sessionLockController,
ownedTranscriptWriteContext: input.sessionLock.ownedTranscriptWriteContext,
runAbortController: input.runAbortController,
externalAbortController: input.externalAbortController,
@@ -162,8 +162,7 @@ function createFixture() {
},
},
sessionLock: {
sessionLockController: {},
withOwnedSessionWriteLock: vi.fn(),
withOwnedTranscriptWrite: vi.fn(),
},
setup: {
effectiveFsWorkspaceOnly: false,
@@ -172,8 +172,7 @@ export async function runEmbeddedAttemptSettledPhase(
attempt,
activeSession,
sessionManager,
sessionLockController: input.sessionLock.sessionLockController,
withOwnedSessionWriteLock: input.sessionLock.withOwnedSessionWriteLock,
withOwnedTranscriptWrite: input.sessionLock.withOwnedTranscriptWrite,
getCompactionReserveTokens: () => settingsManager.getCompactionReserveTokens(),
...(emptyExplicitToolAllowlistError ? { emptyExplicitToolAllowlistError } : {}),
assembly: {
@@ -308,8 +307,7 @@ export async function runEmbeddedAttemptSettledPhase(
attempt,
activeSession,
sessionManager,
sessionLockController: input.sessionLock.sessionLockController,
withOwnedSessionWriteLock: input.sessionLock.withOwnedSessionWriteLock,
withOwnedTranscriptWrite: input.sessionLock.withOwnedTranscriptWrite,
waitForPendingEvents,
repairedRejectedThinkingReplay: input.getRepairedRejectedThinkingReplay(),
getRunAbortDeadlineAtMs,
@@ -6,18 +6,18 @@ import type {
} from "./attempt-abort.js";
import type { prepareEmbeddedAttemptBootstrap } from "./attempt-bootstrap-prepare.js";
import type { prepareEmbeddedAttemptBundleTools } from "./attempt-bundle-tools.js";
import type { prepareEmbeddedAttemptSessionLock } from "./attempt-session-lock-prepare.js";
import type { prepareEmbeddedAttemptSessionRuntime } from "./attempt-session-runtime-prepare.js";
import type { prepareEmbeddedAttemptSetup } from "./attempt-setup.js";
import type { prepareEmbeddedAttemptStreamRuntime } from "./attempt-stream-runtime-prepare.js";
import type { prepareEmbeddedAttemptSystemPrompt } from "./attempt-system-prompt-prepare.js";
import type { prepareEmbeddedAttemptToolBase } from "./attempt-tool-base-prepare.js";
import type { prepareEmbeddedAttemptToolCatalog } from "./attempt-tool-catalog.js";
import type { prepareEmbeddedAttemptTranscriptLifecycle } from "./attempt-transcript-lifecycle-prepare.js";
import type { EmbeddedRunAttemptParams } from "./types.js";
type Prepared<T extends (...args: never[]) => unknown> = Awaited<ReturnType<T>>;
type PreparedSetup = Prepared<typeof prepareEmbeddedAttemptSetup>;
type PreparedSessionLock = Prepared<typeof prepareEmbeddedAttemptSessionLock>;
type PreparedTranscriptLifecycle = Prepared<typeof prepareEmbeddedAttemptTranscriptLifecycle>;
type StreamRuntimeInput = Parameters<typeof prepareEmbeddedAttemptStreamRuntime>[0];
type AttemptContextEngine = NonNullable<StreamRuntimeInput["history"]["activeContextEngine"]>;
@@ -49,11 +49,8 @@ export type EmbeddedAttemptExecutionPhaseInput = {
toolCatalog: ReturnType<typeof prepareEmbeddedAttemptToolCatalog>;
};
sessionLock: Pick<
PreparedSessionLock,
| "compactionTimeoutMs"
| "ownedTranscriptWriteContext"
| "sessionLockController"
| "withOwnedSessionWriteLock"
PreparedTranscriptLifecycle,
"compactionTimeoutMs" | "ownedTranscriptWriteContext" | "withOwnedTranscriptWrite"
>;
setup: Pick<
PreparedSetup,
@@ -57,8 +57,7 @@ describe("embedded attempt phase lifecycle state", () => {
} as never,
activeSession: activeSession as never,
sessionManager: sessionManager as never,
sessionLockController: {} as never,
withOwnedSessionWriteLock: async (operation) => await operation(),
withOwnedTranscriptWrite: async (operation) => await operation(),
subscription: {
toolMetas: [],
waitForCompactionRetry: async () => {
@@ -138,8 +137,7 @@ describe("embedded attempt phase lifecycle state", () => {
} as never,
activeSession: activeSession as never,
sessionManager: sessionManager as never,
sessionLockController: {} as never,
withOwnedSessionWriteLock: async (operation) => await operation(),
withOwnedTranscriptWrite: async (operation) => await operation(),
subscription: {
toolMetas: [{ toolName: "exec", asyncStarted: true }],
waitForCompactionRetry: async () => {},
@@ -224,8 +222,7 @@ describe("embedded attempt phase lifecycle state", () => {
} as never,
activeSession: activeSession as never,
sessionManager: sessionManager as never,
sessionLockController: {} as never,
withOwnedSessionWriteLock: async (operation) => await operation(),
withOwnedTranscriptWrite: async (operation) => await operation(),
subscription: {
toolMetas: [
{ toolName: "read", isError: true },
@@ -321,8 +318,7 @@ describe("embedded attempt phase lifecycle state", () => {
} as never,
activeSession: {} as never,
sessionManager: { appendCustomEntry: vi.fn(), getLeafId: vi.fn(() => "terminal") } as never,
sessionLockController: {} as never,
withOwnedSessionWriteLock: async (operation) => await operation(),
withOwnedTranscriptWrite: async (operation) => await operation(),
state: {
promptError: null,
yieldAborted: false,
@@ -371,8 +367,7 @@ describe("embedded attempt phase lifecycle state", () => {
} as never,
activeSession: {} as never,
sessionManager: { appendCustomEntry: vi.fn() } as never,
sessionLockController: {} as never,
withOwnedSessionWriteLock: async (operation) => await operation(),
withOwnedTranscriptWrite: async (operation) => await operation(),
state: {
promptError: abortError,
yieldAborted: false,
@@ -420,8 +415,7 @@ describe("embedded attempt phase lifecycle state", () => {
} as never,
activeSession: {} as never,
sessionManager: { appendCustomEntry: vi.fn() } as never,
sessionLockController: {} as never,
withOwnedSessionWriteLock: async (operation) => {
withOwnedTranscriptWrite: async (operation) => {
aborted = true;
return await operation();
},
@@ -473,8 +467,7 @@ describe("embedded attempt phase lifecycle state", () => {
} as never,
activeSession: {} as never,
sessionManager: { appendCustomEntry: vi.fn() } as never,
sessionLockController: {} as never,
withOwnedSessionWriteLock: async (operation) => await operation(),
withOwnedTranscriptWrite: async (operation) => await operation(),
state: {
promptError: null,
yieldAborted: false,
@@ -16,7 +16,7 @@ type PromptDispatchState = PromptPreflightInput["state"];
export async function dispatchEmbeddedAttemptPrompt(input: {
attempt: EmbeddedRunAttemptParams;
activeContextEngine?: PromptPreflightInput["activeContextEngine"];
activeSession: PromptExecutionInput["session"] & PromptSubmissionInput["activeSession"];
activeSession: PromptSubmissionInput["activeSession"];
promptContext: PromptContext;
getCompactionReserveTokens: () => number;
publishState: (state: PromptDispatchState) => void;
@@ -71,7 +71,6 @@ export async function dispatchEmbeddedAttemptPrompt(input: {
...input.execution,
attempt,
prompt: promptContext.promptSubmission.prompt,
session: activeSession,
skipPromptSubmission: input.state.skipPromptSubmission,
});
@@ -6,11 +6,10 @@ const hoisted = vi.hoisted(() => ({
isSessionsYieldAbortError: vi.fn(() => false),
markYieldAborted: vi.fn(),
persistSessionsYieldContextMessage: vi.fn(async () => undefined),
releaseHeldLockForAbort: vi.fn(async () => undefined),
releaseLeasedSteering: vi.fn(),
stripSessionsYieldArtifacts: vi.fn(),
waitForSessionsYieldAbortSettle: vi.fn(async () => undefined),
withOwnedSessionWriteLock: vi.fn(async (operation: () => unknown) => await operation()),
withOwnedTranscriptWrite: vi.fn(async (operation: () => unknown) => await operation()),
}));
vi.mock("./attempt.sessions-yield.js", () => ({
@@ -35,10 +34,7 @@ function createInput(overrides: Partial<PromptErrorInput> = {}): PromptErrorInpu
handleMidTurnPrecheckRequest: hoisted.handleMidTurnPrecheckRequest,
markYieldAborted: hoisted.markYieldAborted,
releaseLeasedSteering: hoisted.releaseLeasedSteering,
sessionLockController: {
releaseHeldLockForAbort: hoisted.releaseHeldLockForAbort,
},
withOwnedSessionWriteLock: hoisted.withOwnedSessionWriteLock,
withOwnedTranscriptWrite: hoisted.withOwnedTranscriptWrite,
yieldAbortSettled: null,
yieldDetected: false,
yieldMessage: null,
@@ -63,7 +59,7 @@ describe("handleEmbeddedAttemptPromptError", () => {
expect(hoisted.releaseLeasedSteering).toHaveBeenCalledWith(error);
});
it("routes mid-turn prechecks under the owned session lock", async () => {
it("routes mid-turn prechecks under the owned transcript context", async () => {
const request = {
route: "compact_only",
estimatedPromptTokens: 12,
@@ -77,7 +73,7 @@ describe("handleEmbeddedAttemptPromptError", () => {
await expect(handleEmbeddedAttemptPromptError(createInput({ error }))).resolves.toEqual({});
expect(hoisted.withOwnedSessionWriteLock).toHaveBeenCalledOnce();
expect(hoisted.withOwnedTranscriptWrite).toHaveBeenCalledOnce();
expect(hoisted.handleMidTurnPrecheckRequest).toHaveBeenCalledWith(request);
});
@@ -100,7 +96,6 @@ describe("handleEmbeddedAttemptPromptError", () => {
runId: "run-1",
sessionId: "session-1",
});
expect(hoisted.releaseHeldLockForAbort).toHaveBeenCalledOnce();
expect(hoisted.stripSessionsYieldArtifacts).toHaveBeenCalledWith(input.activeSession);
expect(hoisted.persistSessionsYieldContextMessage).toHaveBeenCalledWith(
input.activeSession,
@@ -1,6 +1,5 @@
/** Classifies prompt failures and performs yield or mid-turn recovery. */
import type { AgentSession } from "../../sessions/index.js";
import type { EmbeddedAttemptSessionLockController } from "./attempt.session-lock.js";
import {
isSessionsYieldAbortError,
persistSessionsYieldContextMessage,
@@ -11,11 +10,7 @@ import { isMidTurnPrecheckSignal, type MidTurnPrecheckRequest } from "./midturn-
import type { EmbeddedRunAttemptParams } from "./types.js";
type PromptErrorAttempt = Pick<EmbeddedRunAttemptParams, "runId" | "sessionId">;
type PromptErrorSessionLockController = Pick<
EmbeddedAttemptSessionLockController,
"releaseHeldLockForAbort"
>;
type WithOwnedSessionWriteLock = <T>(operation: () => Promise<T> | T) => Promise<T>;
type WithOwnedTranscriptWrite = <T>(operation: () => Promise<T> | T) => Promise<T>;
type EmbeddedAttemptPromptErrorOutcome = {
promptFailure?: {
@@ -31,8 +26,7 @@ export async function handleEmbeddedAttemptPromptError(input: {
handleMidTurnPrecheckRequest: (request: MidTurnPrecheckRequest) => void;
markYieldAborted: () => void;
releaseLeasedSteering: (error?: unknown) => void;
sessionLockController: PromptErrorSessionLockController;
withOwnedSessionWriteLock: WithOwnedSessionWriteLock;
withOwnedTranscriptWrite: WithOwnedTranscriptWrite;
yieldAbortSettled: Promise<void> | null;
yieldDetected: boolean;
yieldMessage: string | null;
@@ -47,8 +41,7 @@ export async function handleEmbeddedAttemptPromptError(input: {
runId: input.attempt.runId,
sessionId: input.attempt.sessionId,
});
await input.sessionLockController.releaseHeldLockForAbort({ terminal: false });
await input.withOwnedSessionWriteLock(async () => {
await input.withOwnedTranscriptWrite(async () => {
stripSessionsYieldArtifacts(input.activeSession);
if (input.yieldMessage) {
await persistSessionsYieldContextMessage(input.activeSession, input.yieldMessage);
@@ -59,7 +52,7 @@ export async function handleEmbeddedAttemptPromptError(input: {
if (isMidTurnPrecheckSignal(input.error)) {
const request = input.error.request;
await input.withOwnedSessionWriteLock(() => {
await input.withOwnedTranscriptWrite(() => {
input.handleMidTurnPrecheckRequest(request);
});
return {};
@@ -1,14 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hoisted = vi.hoisted(() => ({
canAdvanceSessionEntryCache: vi.fn(() => true),
detectAndLoadPromptImages: vi.fn(),
installPromptSubmissionLockRelease: vi.fn((_input: Record<string, unknown>) => undefined),
publishOwnedSessionFileSnapshot: vi.fn(() => true),
reacquireAfterPrompt: vi.fn(async () => undefined),
releaseForPrompt: vi.fn(async () => undefined),
resolveImageSanitizationLimits: vi.fn(() => ({ maxDimensionPx: 2048 })),
withSessionWriteLock: vi.fn(async (operation: () => unknown) => await operation()),
}));
vi.mock("@openclaw/media-core/constants", () => ({
@@ -22,9 +16,6 @@ vi.mock("../../image-sanitization.js", () => ({
vi.mock("./images.js", () => ({
detectAndLoadPromptImages: hoisted.detectAndLoadPromptImages,
}));
vi.mock("./attempt.session-lock.js", () => ({
installPromptSubmissionLockRelease: hoisted.installPromptSubmissionLockRelease,
}));
import { prepareEmbeddedAttemptPromptExecution } from "./attempt-prompt-execution-prepare.js";
@@ -53,14 +44,6 @@ function createInput(overrides: Partial<PromptExecutionInput> = {}): PromptExecu
fsBridge: { readFile: vi.fn() },
workspaceDir: "/sandbox/workspace",
},
session: { agent: { streamFn: vi.fn() } },
sessionLockController: {
canAdvanceSessionEntryCache: hoisted.canAdvanceSessionEntryCache,
publishOwnedSessionFileSnapshot: hoisted.publishOwnedSessionFileSnapshot,
reacquireAfterPrompt: hoisted.reacquireAfterPrompt,
releaseForPrompt: hoisted.releaseForPrompt,
withSessionWriteLock: hoisted.withSessionWriteLock,
},
skipPromptSubmission: false,
...overrides,
} as PromptExecutionInput;
@@ -97,32 +80,14 @@ describe("prepareEmbeddedAttemptPromptExecution", () => {
loadedCount: 0,
skippedCount: 0,
});
expect(hoisted.installPromptSubmissionLockRelease).not.toHaveBeenCalled();
expect(hoisted.detectAndLoadPromptImages).not.toHaveBeenCalled();
});
it("installs the lock handoff before loading prompt images", async () => {
it("loads prompt images with the prepared workspace policy", async () => {
const input = createInput();
const result = await prepareEmbeddedAttemptPromptExecution(input);
expect(hoisted.installPromptSubmissionLockRelease).toHaveBeenCalledWith(
expect.objectContaining({
session: input.session,
sessionFile: "/tmp/session.jsonl",
sessionKey: "agent:main:session-1",
}),
);
const lockHandoff = hoisted.installPromptSubmissionLockRelease.mock.calls[0]?.[0] as
| {
reacquireAfterPrompt: () => Promise<void>;
releaseForPrompt: () => Promise<void>;
}
| undefined;
await lockHandoff?.releaseForPrompt();
await lockHandoff?.reacquireAfterPrompt();
expect(hoisted.releaseForPrompt).toHaveBeenCalledOnce();
expect(hoisted.reacquireAfterPrompt).toHaveBeenCalledOnce();
expect(hoisted.detectAndLoadPromptImages).toHaveBeenCalledWith({
prompt: "inspect image.png",
workspaceDir: "/tmp/workspace",
@@ -152,7 +117,6 @@ describe("prepareEmbeddedAttemptPromptExecution", () => {
await prepareEmbeddedAttemptPromptExecution(input);
expect(hoisted.installPromptSubmissionLockRelease).toHaveBeenCalledOnce();
expect(hoisted.detectAndLoadPromptImages).toHaveBeenCalledWith(
expect.objectContaining({ sandbox: undefined }),
);
@@ -1,14 +1,8 @@
/** Prepares prompt-lock ownership and prompt-local images for submission. */
import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants";
import type { OwnedSessionTranscriptCacheSnapshot } from "../../../config/sessions/transcript-write-context.js";
import { readPersistedMediaFacts } from "../../../media/media-facts.js";
import { resolveImageSanitizationLimits } from "../../image-sanitization.js";
import type { SandboxContext } from "../../sandbox/types.js";
import type { AgentSession } from "../../sessions/index.js";
import {
type EmbeddedAttemptSessionLockController,
installPromptSubmissionLockRelease,
} from "./attempt.session-lock.js";
import { detectAndLoadPromptImages } from "./images.js";
import { readPersistedMediaImageLayout } from "./prompt-image-metadata.js";
import type { EmbeddedRunAttemptParams } from "./types.js";
@@ -44,8 +38,6 @@ export async function prepareEmbeddedAttemptPromptExecution(input: {
effectiveWorkspace: string;
prompt: string;
sandbox?: SandboxContext | null;
session: AgentSession;
sessionLockController: EmbeddedAttemptSessionLockController;
skipPromptSubmission: boolean;
}): Promise<PromptImageResult> {
if (input.skipPromptSubmission) {
@@ -53,22 +45,6 @@ export async function prepareEmbeddedAttemptPromptExecution(input: {
}
const { attempt } = input;
installPromptSubmissionLockRelease({
session: input.session,
releaseForPrompt: () => input.sessionLockController.releaseForPrompt(),
reacquireAfterPrompt: () => input.sessionLockController.reacquireAfterPrompt(),
sessionKey: attempt.sessionKey,
sessionFile: attempt.sessionFile,
sessionTarget: attempt.sessionTarget,
withSessionWriteLock: (run, options) =>
input.sessionLockController.withSessionWriteLock(run, options),
assertSessionWriteLockOwned: () => input.sessionLockController.assertOwned(),
canAdvanceSessionEntryCache: (snapshot: OwnedSessionTranscriptCacheSnapshot) =>
input.sessionLockController.canAdvanceSessionEntryCache(snapshot),
publishSessionFileSnapshot: (snapshot: OwnedSessionTranscriptCacheSnapshot) =>
input.sessionLockController.publishOwnedSessionFileSnapshot(snapshot),
});
const persistedMessage =
attempt.userTurnTranscriptRecorder?.message ??
(await attempt.userTurnTranscriptRecorder?.resolveMessage());
@@ -164,7 +164,6 @@ function createFixture() {
appendCustomEntry: vi.fn(),
getEntries: vi.fn(() => []),
};
const sessionLockController = {};
const input = {
attempt: {
model: { id: "model-1", provider: "test" },
@@ -175,8 +174,7 @@ function createFixture() {
},
activeSession,
sessionManager,
sessionLockController,
withOwnedSessionWriteLock: async <T>(operation: () => Promise<T> | T) => await operation(),
withOwnedTranscriptWrite: async <T>(operation: () => Promise<T> | T) => await operation(),
getCompactionReserveTokens: () => 77,
assembly: {
hookRunner: null,
@@ -41,7 +41,7 @@ type PromptContextPhaseInput = Omit<
PromptContextInput,
"attempt" | "messages" | "prompt" | "replaceSessionMessages"
>;
type PromptExecutionPhaseInput = Omit<PromptDispatchInput["execution"], "sessionLockController">;
type PromptExecutionPhaseInput = PromptDispatchInput["execution"];
type PromptObservationPhaseInput = Omit<PromptDispatchInput["observation"], "transcriptLeafId">;
type PromptToolSurface = ReturnType<typeof applyPromptBuildToolsAllow>;
type PromptPreflightPhaseInput = PromptDispatchInput["preflight"] & {
@@ -54,14 +54,13 @@ type PromptSubmissionPhaseInput = Pick<
| "toolResultPromptProjectionState"
| "trajectoryRecorder"
>;
type WithOwnedSessionWriteLock = <T>(operation: () => Promise<T> | T) => Promise<T>;
type WithOwnedTranscriptWrite = <T>(operation: () => Promise<T> | T) => Promise<T>;
export async function runEmbeddedAttemptPromptPhase(input: {
attempt: PromptAssemblyInput["attempt"];
activeSession: PromptAssemblyInput["activeSession"];
sessionManager: PromptAssemblyInput["sessionManager"];
sessionLockController: PromptDispatchInput["execution"]["sessionLockController"];
withOwnedSessionWriteLock: WithOwnedSessionWriteLock;
withOwnedTranscriptWrite: WithOwnedTranscriptWrite;
getCompactionReserveTokens: () => number;
emptyExplicitToolAllowlistError?: Error;
assembly: PromptAssemblyPhaseInput;
@@ -223,7 +222,7 @@ export async function runEmbeddedAttemptPromptPhase(input: {
modelPrompt: promptForModel,
sessionManager,
systemPrompt: systemPromptForHook,
withOwnedSessionWriteLock: input.withOwnedSessionWriteLock,
withOwnedTranscriptWrite: input.withOwnedTranscriptWrite,
});
if (beforeAgentRunOutcome) {
input.lifecycle.markBeforeAgentRunBlocked(beforeAgentRunOutcome);
@@ -248,7 +247,7 @@ export async function runEmbeddedAttemptPromptPhase(input: {
provider: attempt.provider,
sessionManager: {
appendCustomEntry: async (customType, data) => {
await input.withOwnedSessionWriteLock(() => {
await input.withOwnedTranscriptWrite(() => {
sessionManager.appendCustomEntry(customType, data);
});
},
@@ -276,10 +275,7 @@ export async function runEmbeddedAttemptPromptPhase(input: {
...input.lifecycle.readState(),
skipPromptSubmission,
},
execution: {
...input.execution,
sessionLockController: input.sessionLockController,
},
execution: input.execution,
observation: {
...input.observation,
...(promptToolSurface
@@ -313,8 +309,7 @@ export async function runEmbeddedAttemptPromptPhase(input: {
handleMidTurnPrecheckRequest,
markYieldAborted: input.lifecycle.markYieldAborted,
releaseLeasedSteering,
sessionLockController: input.sessionLockController,
withOwnedSessionWriteLock: input.withOwnedSessionWriteLock,
withOwnedTranscriptWrite: input.withOwnedTranscriptWrite,
...input.lifecycle.readYieldState(),
});
if (promptErrorOutcome.promptFailure) {
@@ -332,7 +327,7 @@ export async function runEmbeddedAttemptPromptPhase(input: {
const pendingMidTurnPrecheckRequest = input.lifecycle.takePendingMidTurnPrecheckRequest();
if (pendingMidTurnPrecheckRequest) {
await input.withOwnedSessionWriteLock(() => {
await input.withOwnedTranscriptWrite(() => {
removeTrailingMidTurnPrecheckAssistantError({ activeSession, sessionManager });
const state = input.lifecycle.readState();
if (!state.preflightRecovery && state.promptErrorSource !== "precheck") {
@@ -29,9 +29,9 @@ const attempt = {
} as never;
function createInput(overrides: Record<string, unknown> = {}) {
const sessionLockController = {
acquireForCleanup: vi.fn(async () => ({ release: vi.fn() })),
hasSessionTakeover: vi.fn(() => false),
const transcriptLifecycle = {
beginCleanup: vi.fn(async () => undefined),
dispose: vi.fn(async () => undefined),
};
const emitDiagnosticRunCompleted = vi.fn();
const trajectoryRecorder = {
@@ -52,7 +52,7 @@ function createInput(overrides: Record<string, unknown> = {}) {
};
return {
attempt,
sessionLockController,
transcriptLifecycle,
sessionAgentId: "main",
buildAbortSettlePromise: () => null,
trajectoryRecorder,
@@ -71,7 +71,7 @@ describe("cleanupEmbeddedAttemptSessionPhase", () => {
hoisted.flushEmbeddedAttemptTrajectoryRecorder.mockResolvedValue(undefined);
});
it("records the terminal event before lock-safe resource cleanup", async () => {
it("records the terminal event before transcript-safe resource cleanup", async () => {
const input = createInput();
await cleanupEmbeddedAttemptSessionPhase(input as never);
@@ -91,8 +91,10 @@ describe("cleanupEmbeddedAttemptSessionPhase", () => {
expect.objectContaining({ runId: "run-1", sessionId: "session-1", agentId: "main" }),
);
expect(hoisted.cleanupEmbeddedAttemptResources).toHaveBeenCalledWith(
expect.objectContaining({ aborted: false, skipSessionFlush: false }),
expect.objectContaining({ aborted: false }),
);
expect(input.transcriptLifecycle.beginCleanup).toHaveBeenCalledOnce();
expect(input.transcriptLifecycle.dispose).toHaveBeenCalledOnce();
expect(input.emitDiagnosticRunCompleted).toHaveBeenCalledWith("completed", null, undefined);
});
@@ -126,38 +128,4 @@ describe("cleanupEmbeddedAttemptSessionPhase", () => {
undefined,
);
});
it("preserves the prompt error when cleanup detects session takeover", async () => {
const promptError = new Error("prompt failed");
const sessionLockController = {
acquireForCleanup: vi.fn(async () => ({ release: vi.fn() })),
hasSessionTakeover: vi.fn(() => true),
};
const emitDiagnosticRunCompleted = vi.fn();
const input = createInput({
sessionLockController,
emitDiagnosticRunCompleted,
trajectoryRecorder: null,
readState: () => ({
aborted: false,
externalAbort: false,
timedOut: false,
idleTimedOut: false,
timedOutDuringCompaction: false,
timedOutDuringToolExecution: false,
timedOutByRunBudget: false,
promptError,
beforeAgentRunBlocked: false,
}),
});
await expect(cleanupEmbeddedAttemptSessionPhase(input as never)).rejects.toMatchObject({
name: "EmbeddedAttemptSessionTakeoverError",
promptError,
});
expect(hoisted.cleanupEmbeddedAttemptResources).toHaveBeenCalledWith(
expect.objectContaining({ skipSessionFlush: true }),
);
expect(emitDiagnosticRunCompleted).toHaveBeenCalledWith("error", promptError, undefined);
});
});
@@ -10,16 +10,11 @@ import { log } from "../logger.js";
import { flushPendingToolResultsAfterIdle } from "../wait-for-idle-before-flush.js";
import type { EmitDiagnosticRunCompleted } from "./attempt-startup.js";
import { flushEmbeddedAttemptTrajectoryRecorder } from "./attempt-trajectory-flush-cleanup.js";
import {
type createEmbeddedAttemptSessionLockController,
EmbeddedAttemptSessionTakeoverError,
} from "./attempt.session-lock.js";
import type { createEmbeddedAttemptTranscriptLifecycle } from "./attempt-transcript-lifecycle.js";
import { cleanupEmbeddedAttemptResources } from "./attempt.subscription-cleanup.js";
import type { EmbeddedRunAttemptParams } from "./types.js";
type AttemptSessionLockController = Awaited<
ReturnType<typeof createEmbeddedAttemptSessionLockController>
>;
type AttemptTranscriptLifecycle = ReturnType<typeof createEmbeddedAttemptTranscriptLifecycle>;
type TrajectoryRecorder = ReturnType<typeof createTrajectoryRuntimeRecorder>;
type DisposableRuntime = { dispose(): Promise<void> | void };
@@ -27,7 +22,7 @@ type CleanupEmbeddedAttemptSessionInput = {
attempt: EmbeddedRunAttemptParams;
session?: AgentSession;
sessionManager?: ReturnType<typeof guardSessionManager>;
sessionLockController: AttemptSessionLockController;
transcriptLifecycle: AttemptTranscriptLifecycle;
bundleMcpRuntime?: DisposableRuntime;
bundleLspRuntime?: DisposableRuntime;
removeToolResultContextGuard?: () => void;
@@ -53,28 +48,6 @@ type CleanupEmbeddedAttemptSessionInput = {
};
};
class EmbeddedAttemptPromptErrorWithCleanupTakeoverError extends Error {
readonly promptError: unknown;
readonly cleanupError: EmbeddedAttemptSessionTakeoverError;
constructor(params: { promptError: unknown; cleanupError: EmbeddedAttemptSessionTakeoverError }) {
super(formatErrorMessage(params.promptError), { cause: params.cleanupError });
this.name = "EmbeddedAttemptSessionTakeoverError";
this.promptError = params.promptError;
this.cleanupError = params.cleanupError;
}
}
function shouldPreservePromptErrorAfterCleanupError(params: {
promptError: unknown;
cleanupError: unknown;
}): boolean {
return (
Boolean(params.promptError) &&
params.cleanupError instanceof EmbeddedAttemptSessionTakeoverError
);
}
export async function cleanupEmbeddedAttemptSessionPhase(
input: CleanupEmbeddedAttemptSessionInput,
): Promise<void> {
@@ -128,9 +101,7 @@ export async function cleanupEmbeddedAttemptSessionPhase(
cleanupState.idleTimedOut ||
cleanupState.timedOutDuringCompaction;
const cleanupAbortLike = cleanupAborted || input.cleanupYieldAborted;
const cleanupSessionLock = await input.sessionLockController.acquireForCleanup({
session: input.session,
});
await input.transcriptLifecycle.beginCleanup();
await cleanupEmbeddedAttemptResources({
removeToolResultContextGuard: input.removeToolResultContextGuard,
flushPendingToolResultsAfterIdle,
@@ -138,28 +109,24 @@ export async function cleanupEmbeddedAttemptSessionPhase(
sessionManager: input.sessionManager,
bundleMcpRuntime: input.bundleMcpRuntime,
bundleLspRuntime: input.bundleLspRuntime,
sessionLock: cleanupSessionLock,
// Aborted runs skip the idle wait so teardown cannot strand the lock.
aborted: cleanupAbortLike,
abortSettlePromise: cleanupAborted ? input.buildAbortSettlePromise() : null,
skipSessionFlush: input.sessionLockController.hasSessionTakeover(),
runId: attempt.runId,
sessionId: attempt.sessionId,
});
} catch (err) {
cleanupError = err;
} finally {
try {
await input.transcriptLifecycle.dispose();
} catch (err) {
cleanupError ??= err;
}
}
const finalState = input.readState();
const synthesizedCleanupTakeoverError =
!cleanupError && finalState.promptError && input.sessionLockController.hasSessionTakeover()
? new EmbeddedAttemptSessionTakeoverError(attempt.sessionFile)
: undefined;
const cleanupFailure = cleanupError ?? synthesizedCleanupTakeoverError;
const shouldPreservePromptError = shouldPreservePromptErrorAfterCleanupError({
promptError: finalState.promptError,
cleanupError: cleanupFailure,
});
const cleanupFailure = cleanupError;
input.emitDiagnosticRunCompleted?.(
cleanupFailure
? "error"
@@ -173,7 +140,7 @@ export async function cleanupEmbeddedAttemptSessionPhase(
finalState.timedOutDuringCompaction
? "aborted"
: "completed",
shouldPreservePromptError ? finalState.promptError : (cleanupFailure ?? finalState.promptError),
cleanupFailure ?? finalState.promptError,
finalState.beforeAgentRunBlocked
? { blockedBy: finalState.beforeAgentRunBlockedBy ?? "before_agent_run" }
: undefined,
@@ -182,18 +149,5 @@ export async function cleanupEmbeddedAttemptSessionPhase(
if (!cleanupFailure) {
return;
}
if (shouldPreservePromptError) {
log.warn(
`embedded attempt cleanup detected session takeover after prompt failure; preserving prompt error: ` +
`runId=${attempt.runId} sessionId=${attempt.sessionId} ` +
`promptError=${formatErrorMessage(finalState.promptError)} cleanupError=${formatErrorMessage(cleanupFailure)}`,
);
await Promise.reject(
new EmbeddedAttemptPromptErrorWithCleanupTakeoverError({
promptError: finalState.promptError,
cleanupError: cleanupFailure as EmbeddedAttemptSessionTakeoverError,
}),
);
}
await Promise.reject(toErrorObject(cleanupFailure, "Non-Error rejection"));
}
@@ -1,182 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
acquireSessionFileOwner: vi.fn(),
acquireSessionWriteLock: vi.fn(),
createSessionLockController: vi.fn(),
resolveSessionWriteLockTargetKey: vi.fn(() => "canonical-lock-key"),
resolveCompactionTimeoutMs: vi.fn(() => 30_000),
resolveSessionWriteLockOptions: vi.fn(() => ({
timeoutMs: 100,
staleMs: 200,
maxHoldMs: 300,
})),
}));
vi.mock("../../session-write-lock.js", () => ({
acquireSessionWriteLock: mocks.acquireSessionWriteLock,
resolveSessionWriteLockTargetKey: mocks.resolveSessionWriteLockTargetKey,
}));
vi.mock("../compaction-safety-timeout.js", () => ({
resolveCompactionTimeoutMs: mocks.resolveCompactionTimeoutMs,
}));
vi.mock("./attempt.run-decisions.js", () => ({
resolveEmbeddedAttemptSessionWriteLockOptions: mocks.resolveSessionWriteLockOptions,
}));
vi.mock("./attempt.session-lock.js", () => ({
acquireEmbeddedAttemptSessionFileOwner: mocks.acquireSessionFileOwner,
createEmbeddedAttemptSessionLockController: mocks.createSessionLockController,
}));
import { prepareEmbeddedAttemptSessionLock } from "./attempt-session-lock-prepare.js";
type PrepareInput = Parameters<typeof prepareEmbeddedAttemptSessionLock>[0];
type CreateControllerInput = {
acquireSessionWriteLock: unknown;
initialAcquireSignal?: AbortSignal;
lockOptions: {
sessionFile: string;
timeoutMs: number;
staleMs: number;
maxHoldMs: number;
};
reloadPromptReleasedSessionFile: () => void;
};
function createFixture(options?: { rejectPostArmFence?: Error }) {
const order: string[] = [];
const sessionFileOwner = { release: vi.fn() };
const sessionManager = {
reloadPersistedTranscript: vi.fn(),
};
const sessionLockController = {
assertOwned: vi.fn(),
canAdvanceSessionEntryCache: vi.fn(() => true),
dispose: vi.fn(async () => undefined),
publishOwnedSessionFileSnapshot: vi.fn(() => true),
withSessionWriteLock: vi.fn(async <T>(operation: () => Promise<T> | T) => await operation()),
};
mocks.acquireSessionFileOwner.mockImplementation(async () => {
order.push("file-owner");
return sessionFileOwner;
});
mocks.createSessionLockController.mockImplementation(async () => {
order.push("session-lock");
return sessionLockController;
});
let fenceCount = 0;
const externalAbortController = {
arm: vi.fn(() => {
order.push("arm");
}),
throwIfFiredAfterPrepCleanup: vi.fn(async () => {
fenceCount += 1;
order.push(`abort-fence-${fenceCount}`);
if (fenceCount === 2 && options?.rejectPostArmFence) {
throw options.rejectPostArmFence;
}
}),
};
let releaseSessionLock: (() => Promise<void>) | undefined;
const input = {
attempt: {
abortSignal: new AbortController().signal,
config: {},
sessionFile: "/tmp/session.jsonl",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionTarget: {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
storePath: "/tmp/sessions.json",
},
},
externalAbortController,
getSessionManager: () => sessionManager,
onSessionFileOwnerAcquired: vi.fn(() => {
order.push("publish-file-owner");
}),
onSessionLockReleaseReady: vi.fn((release) => {
order.push("publish-session-lock");
releaseSessionLock = release;
}),
} as unknown as PrepareInput;
return {
externalAbortController,
input,
order,
releaseSessionLock: () => releaseSessionLock,
sessionFileOwner,
sessionLockController,
sessionManager,
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("prepareEmbeddedAttemptSessionLock", () => {
it("publishes ownership before arming and exposes owned transcript writes", async () => {
const fixture = createFixture();
const result = await prepareEmbeddedAttemptSessionLock(fixture.input);
expect(fixture.order).toEqual([
"abort-fence-1",
"file-owner",
"publish-file-owner",
"session-lock",
"publish-session-lock",
"arm",
"abort-fence-2",
]);
expect(result.compactionTimeoutMs).toBe(30_000);
expect(mocks.acquireSessionFileOwner).toHaveBeenCalledWith({
sessionFile: "/tmp/session.jsonl",
timeoutMs: 300,
signal: fixture.input.attempt.abortSignal,
});
const controllerInput = mocks.createSessionLockController.mock.calls[0]?.[0] as
| CreateControllerInput
| undefined;
expect(controllerInput).toEqual(
expect.objectContaining({
acquireSessionWriteLock: mocks.acquireSessionWriteLock,
initialAcquireSignal: fixture.input.attempt.abortSignal,
lockOptions: {
sessionFile: "canonical-lock-key",
targetKind: "session-key",
timeoutMs: 100,
staleMs: 200,
maxHoldMs: 300,
},
}),
);
expect(mocks.resolveSessionWriteLockTargetKey).toHaveBeenCalledWith(
fixture.input.attempt.sessionTarget,
);
controllerInput?.reloadPromptReleasedSessionFile();
expect(fixture.sessionManager.reloadPersistedTranscript).toHaveBeenCalledOnce();
await expect(result.withOwnedSessionWriteLock(async () => "done")).resolves.toBe("done");
expect(fixture.sessionLockController.withSessionWriteLock).toHaveBeenCalledOnce();
await fixture.releaseSessionLock()?.();
expect(fixture.sessionLockController.dispose).toHaveBeenCalledOnce();
});
it("publishes both cleanup handles before a post-arm abort fence fails", async () => {
const abortError = new Error("aborted during lock acquisition");
const fixture = createFixture({ rejectPostArmFence: abortError });
await expect(prepareEmbeddedAttemptSessionLock(fixture.input)).rejects.toBe(abortError);
expect(fixture.input.onSessionFileOwnerAcquired).toHaveBeenCalledWith(fixture.sessionFileOwner);
expect(fixture.input.onSessionLockReleaseReady).toHaveBeenCalledOnce();
expect(fixture.externalAbortController.arm).toHaveBeenCalledOnce();
});
});
@@ -1,125 +0,0 @@
/** Acquires and publishes the session-write ownership used by one attempt. */
import { withOwnedSessionTranscriptWrites } from "../../../config/sessions/transcript-write-context.js";
import { resolveAgentRunSessionTarget } from "../../run-session-target.js";
import type { guardSessionManager } from "../../session-tool-result-guard-wrapper.js";
import {
acquireSessionWriteLock,
resolveSessionWriteLockTargetKey,
} from "../../session-write-lock.js";
import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js";
import { resolveEmbeddedAttemptSessionWriteLockOptions } from "./attempt.run-decisions.js";
import {
acquireEmbeddedAttemptSessionFileOwner,
type EmbeddedAttemptSessionFileOwner,
createEmbeddedAttemptSessionLockController,
} from "./attempt.session-lock.js";
import type { EmbeddedRunAttemptParams } from "./types.js";
type AttemptSessionLockController = Awaited<
ReturnType<typeof createEmbeddedAttemptSessionLockController>
>;
type OwnedTranscriptWriteContext = Parameters<typeof withOwnedSessionTranscriptWrites>[0];
type WithOwnedSessionWriteLock = <T>(operation: () => Promise<T> | T) => Promise<T>;
export async function prepareEmbeddedAttemptSessionLock(input: {
attempt: Pick<
EmbeddedRunAttemptParams,
| "abortSignal"
| "config"
| "runId"
| "sessionFile"
| "sessionId"
| "sessionKey"
| "sessionTarget"
>;
externalAbortController: {
arm: () => void;
throwIfFiredAfterPrepCleanup: () => Promise<void>;
};
getSessionManager: () => ReturnType<typeof guardSessionManager> | undefined;
onSessionFileOwnerAcquired: (owner: EmbeddedAttemptSessionFileOwner) => void;
onSessionLockReleaseReady: (release: () => Promise<void>) => void;
}): Promise<{
compactionTimeoutMs: number;
ownedTranscriptWriteContext: OwnedTranscriptWriteContext;
sessionLockController: AttemptSessionLockController;
withOwnedSessionWriteLock: WithOwnedSessionWriteLock;
}> {
const { attempt, externalAbortController } = input;
const compactionTimeoutMs = resolveCompactionTimeoutMs(attempt.config);
const sessionWriteLockOptions = resolveEmbeddedAttemptSessionWriteLockOptions({
config: attempt.config,
compactionTimeoutMs,
});
const sessionTarget = await resolveAgentRunSessionTarget({
agentId: attempt.sessionTarget?.agentId,
config: attempt.config,
missingSessionKey: "resolve-existing",
sessionFile: attempt.sessionFile,
sessionId: attempt.sessionId,
sessionKey: attempt.sessionKey,
sessionTarget: attempt.sessionTarget,
});
await externalAbortController.throwIfFiredAfterPrepCleanup();
const sessionFileOwner = await acquireEmbeddedAttemptSessionFileOwner({
sessionFile: attempt.sessionFile,
timeoutMs: sessionWriteLockOptions.maxHoldMs,
signal: attempt.abortSignal,
});
// Publish ownership immediately so outer teardown can release it if later
// controller setup or the post-arm abort fence fails.
input.onSessionFileOwnerAcquired(sessionFileOwner);
const getSessionManager = () => {
const sessionManager = input.getSessionManager();
if (!sessionManager) {
throw new Error("session manager unavailable during prompt-released file reload");
}
return sessionManager;
};
const sessionLockController = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock,
initialAcquireSignal: attempt.abortSignal,
runId: attempt.runId,
sessionId: attempt.sessionId,
lockOptions: {
sessionFile: resolveSessionWriteLockTargetKey(sessionTarget),
targetKind: "session-key",
...sessionWriteLockOptions,
},
reloadPromptReleasedSessionFile: () => {
getSessionManager().reloadPersistedTranscript();
},
});
input.onSessionLockReleaseReady(() => sessionLockController.dispose());
const ownedTranscriptWriteContext: OwnedTranscriptWriteContext = {
sessionFile: attempt.sessionFile,
sessionKey: attempt.sessionKey,
sessionTarget: attempt.sessionTarget,
assertOwned: () => sessionLockController.assertOwned(),
canAdvanceSessionEntryCache: (snapshot) =>
sessionLockController.canAdvanceSessionEntryCache(snapshot),
publishSessionFileSnapshot: (snapshot) =>
sessionLockController.publishOwnedSessionFileSnapshot(snapshot),
withSessionWriteLock: (operation, options) =>
sessionLockController.withSessionWriteLock(operation, options),
};
const withOwnedSessionWriteLock: WithOwnedSessionWriteLock = (operation) =>
withOwnedSessionTranscriptWrites(ownedTranscriptWriteContext, async () =>
sessionLockController.withSessionWriteLock(operation),
);
externalAbortController.arm();
// The signal can fire while the eager lock is acquired. Recheck after arming
// so a stopped run never reaches session creation or provider prompt.
await externalAbortController.throwIfFiredAfterPrepCleanup();
return {
compactionTimeoutMs,
ownedTranscriptWriteContext,
sessionLockController,
withOwnedSessionWriteLock,
};
}
@@ -9,19 +9,19 @@ import { SessionManager } from "../../sessions/index.js";
import { runContextEngineMaintenance } from "../context-engine-maintenance.js";
import { log } from "../logger.js";
import { resolveExistingAttemptTranscriptState } from "./attempt-transcript-helpers.js";
import type { EmbeddedAttemptTranscriptLifecycle } from "./attempt-transcript-lifecycle.js";
import {
runAttemptContextEngineBootstrap,
type AttemptContextEngine,
} from "./attempt.context-engine-helpers.js";
import { buildAfterTurnRuntimeContext } from "./attempt.prompt-helpers.js";
import type { EmbeddedAttemptSessionLockController } from "./attempt.session-lock.js";
import { resolveAttemptTranscriptPolicy } from "./attempt.transcript-policy.js";
import { createUserTranscriptContextRegistry } from "./attempt.user-transcript-context-registry.js";
import { resolveSessionBoundaryPromptCacheKey } from "./session-boundary-prompt-cache-key.js";
import type { EmbeddedRunAttemptParams } from "./types.js";
type AttemptSessionManager = ReturnType<typeof guardSessionManager>;
type WithOwnedSessionWriteLock = <T>(operation: () => Promise<T> | T) => Promise<T>;
type WithOwnedTranscriptWrite = <T>(operation: () => Promise<T> | T) => Promise<T>;
export async function prepareEmbeddedAttemptSessionManager(input: {
attempt: EmbeddedRunAttemptParams;
activeContextEngine?: AttemptContextEngine;
@@ -32,8 +32,8 @@ export async function prepareEmbeddedAttemptSessionManager(input: {
replayAllowedToolNames: ReadonlySet<string>;
resolveActiveContextEnginePluginId: () => string | undefined;
sessionAgentId: string;
sessionLockController: EmbeddedAttemptSessionLockController;
withOwnedSessionWriteLock: WithOwnedSessionWriteLock;
transcriptLifecycle: EmbeddedAttemptTranscriptLifecycle;
withOwnedTranscriptWrite: WithOwnedTranscriptWrite;
}) {
const { attempt } = input;
const transcriptState = await resolveExistingAttemptTranscriptState({
@@ -95,9 +95,6 @@ export async function prepareEmbeddedAttemptSessionManager(input: {
attempt.suppressTranscriptOnlyAssistantPersistence,
suppressAssistantErrorPersistence: attempt.suppressAssistantErrorPersistence,
skipBeforeMessageWriteHooks: attempt.operation === "settled-tool-finalization",
onMessagePersisted: () => {
input.sessionLockController.refreshAfterOwnedSessionWrite();
},
onUserMessagePreparingForPersistence: (_message, recorder) => {
latestPersistedUserMessage = undefined;
latestUserTurnTranscriptRecorder = recorder;
@@ -131,7 +128,7 @@ export async function prepareEmbeddedAttemptSessionManager(input: {
// even when a context-engine or transcript preparation step fails.
input.onSessionManagerCreated(sessionManager);
await input.withOwnedSessionWriteLock(async () => {
await input.withOwnedTranscriptWrite(async () => {
await runAttemptContextEngineBootstrap({
hadSessionFile: transcriptState.hasBootstrapTranscriptState,
contextEngine: input.activeContextEngine,
@@ -171,8 +171,8 @@ function createFixture() {
replayAllowedToolNames: new Set(["read"]),
resolveActiveContextEnginePluginId: vi.fn(),
sessionAgentId: "main",
sessionLockController: {},
withOwnedSessionWriteLock: vi.fn(),
transcriptLifecycle: {},
withOwnedTranscriptWrite: vi.fn(),
},
agentSession: {
agentCoreThinkingLevel: "medium",
@@ -48,8 +48,8 @@ export async function prepareEmbeddedAttemptSessionRuntime(input: {
| "replayAllowedToolNames"
| "resolveActiveContextEnginePluginId"
| "sessionAgentId"
| "sessionLockController"
| "withOwnedSessionWriteLock"
| "transcriptLifecycle"
| "withOwnedTranscriptWrite"
>;
agentSession: Pick<
AgentSessionInput,
@@ -99,8 +99,8 @@ export async function prepareEmbeddedAttemptSessionRuntime(input: {
replayAllowedToolNames: input.sessionManager.replayAllowedToolNames,
resolveActiveContextEnginePluginId: input.sessionManager.resolveActiveContextEnginePluginId,
sessionAgentId: input.sessionManager.sessionAgentId,
sessionLockController: input.sessionManager.sessionLockController,
withOwnedSessionWriteLock: input.sessionManager.withOwnedSessionWriteLock,
transcriptLifecycle: input.sessionManager.transcriptLifecycle,
withOwnedTranscriptWrite: input.sessionManager.withOwnedTranscriptWrite,
});
const { isOpenAIResponsesApi, preparedUserTurnMessage, sessionManager, transcriptPolicy } =
preparedSessionManager;
@@ -129,7 +129,7 @@ export async function prepareEmbeddedAttemptSessionRuntime(input: {
},
runAbortSignal: input.agentSession.runAbortSignal,
sessionAgentId: input.sessionManager.sessionAgentId,
sessionLockController: input.sessionManager.sessionLockController,
transcriptLifecycle: input.sessionManager.transcriptLifecycle,
sessionManager,
});
const { activeSession, setActiveSessionSystemPrompt, settingsManager } = preparedAgentSession;
@@ -109,8 +109,8 @@ function createInput(options?: {
setActiveToolsByName,
} as unknown as AgentSession;
const sessionManager = { id: "session-manager" };
const sessionLockController = {
withSessionWriteLock: vi.fn(async (operation: () => unknown) => await operation()),
const transcriptLifecycle = {
withTranscriptWrite: vi.fn(async (operation: () => unknown) => await operation()),
};
const hookRunner = { id: "hooks" };
const sessionToolAllowlist = [{ name: "read" }];
@@ -181,7 +181,7 @@ function createInput(options?: {
},
runAbortSignal: new AbortController().signal,
sessionAgentId: "agent-1",
sessionLockController: sessionLockController as never,
transcriptLifecycle: transcriptLifecycle as never,
sessionManager: sessionManager as never,
},
onDeliveredSourceReply: () => onDeliveredSourceReply?.(),
@@ -21,8 +21,8 @@ import { log } from "../logger.js";
import { createEmbeddedAgentResourceLoader } from "../resource-loader.js";
import { applySystemPromptToSession } from "../system-prompt.js";
import { prepareEmbeddedAttemptClientTools } from "./attempt-client-tools.js";
import type { EmbeddedAttemptTranscriptLifecycle } from "./attempt-transcript-lifecycle.js";
import type { AttemptContextEngine } from "./attempt.context-engine-helpers.js";
import type { EmbeddedAttemptSessionLockController } from "./attempt.session-lock.js";
import { installCodeModeRepairHook } from "./code-mode-repair.js";
import { installMessageToolOnlyTerminalHook } from "./message-tool-terminal.js";
import { notifyToolActivity } from "./tool-activity-heartbeat.js";
@@ -54,7 +54,7 @@ export async function prepareEmbeddedAttemptAgentSession(input: {
onSystemPromptChanged: (systemPrompt: string) => void;
runAbortSignal: AbortSignal;
sessionAgentId: string;
sessionLockController: EmbeddedAttemptSessionLockController;
transcriptLifecycle: EmbeddedAttemptTranscriptLifecycle;
sessionManager: AttemptSessionManager;
}) {
const { attempt } = input;
@@ -165,8 +165,8 @@ export async function prepareEmbeddedAttemptAgentSession(input: {
return hydratedTool;
}
: undefined,
withSessionWriteLock: (operation) =>
input.sessionLockController.withSessionWriteLock(operation),
withSessionWriteSettlement: (operation) =>
input.transcriptLifecycle.withTranscriptWrite(operation),
};
const createdSession = await createAgentSessionForEmbeddedRunner(sessionOptions, {
// Without a resolved model budget, the outer loop cannot own bounded recovery.
@@ -43,13 +43,7 @@ function createFixture(overrides?: Partial<FinalizeInput>) {
sessionManager: {
buildSessionContext: () => ({ messages: repairedMessages }),
},
sessionLockController: {
releaseForPrompt: vi.fn(async () => {
order.push("release-prompt-lock");
}),
isPromptSubmissionBlockedError: vi.fn(() => false),
},
withOwnedSessionWriteLock: vi.fn(async (operation) => await operation()),
withOwnedTranscriptWrite: vi.fn(async (operation) => await operation()),
waitForPendingEvents: vi.fn(async () => {
order.push("pending-events");
}),
@@ -278,13 +272,7 @@ describe("finalizeEmbeddedAttemptStreamPhase", () => {
});
expect(fixture.activeSession.agent.state.messages).toBe(fixture.repairedMessages);
expect(fixture.order).toEqual([
"pending-events",
"release-prompt-lock",
"settle",
"settled-published",
"after-turn",
]);
expect(fixture.order).toEqual(["pending-events", "settle", "settled-published", "after-turn"]);
expect(mocks.settleStream).toHaveBeenCalledWith(
expect.objectContaining({
runAbortDeadlineAtMs: 123,
@@ -387,17 +375,9 @@ describe("finalizeEmbeddedAttemptStreamPhase", () => {
expect(mocks.settleStream).toHaveBeenCalledOnce();
});
it("settles an aborted run when prompt release returns its recorded cancellation reason", async () => {
it("settles an aborted run with its recorded cancellation reason", async () => {
const cancellationReason = new Error("cancelled by operator");
const fixture = createFixture({
sessionLockController: {
releaseForPrompt: vi.fn(async () => {
throw cancellationReason;
}),
isPromptSubmissionBlockedError: (error: unknown) => error === cancellationReason,
} as never,
repairedRejectedThinkingReplay: false,
});
const fixture = createFixture({ repairedRejectedThinkingReplay: false });
fixture.input.settle.readLifecycleState = () => ({
aborted: true,
timedOut: false,
@@ -455,50 +435,6 @@ describe("finalizeEmbeddedAttemptStreamPhase", () => {
expect(mocks.completeAfterTurn).not.toHaveBeenCalled();
});
it("restores the rewound in-memory branch when prompt lock release fails", async () => {
const sessionManager = SessionManager.inMemory();
const promptId = sessionManager.appendMessage({
role: "user",
content: "Original request",
timestamp: 1,
});
const rejectedId = sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: "Rejected first answer" }],
stopReason: "stop",
timestamp: 2,
} as never);
const originalMessages = sessionManager.buildSessionContext().messages;
const activeSession = { agent: { state: { messages: originalMessages } } };
const releaseError = new Error("prompt lock release failed");
const fixture = createFixture({
activeSession: activeSession as never,
sessionManager: sessionManager as never,
sessionLockController: {
releaseForPrompt: vi.fn(async () => {
throw releaseError;
}),
isPromptSubmissionBlockedError: () => false,
} as never,
repairedRejectedThinkingReplay: false,
getBeforeAgentFinalizeRevisionEntryId: () => rejectedId,
});
await expect(finalizeEmbeddedAttemptStreamPhase(fixture.input)).rejects.toBe(releaseError);
expect(sessionManager.getLeafId()).toBe(promptId);
expect(activeSession.agent.state.messages).toEqual(
sessionManager.buildSessionContext().messages,
);
expect(JSON.stringify(activeSession.agent.state.messages)).not.toContain(
"Rejected first answer",
);
expect(mocks.settleStream).not.toHaveBeenCalled();
expect(fixture.input.onSettleErrorState).not.toHaveBeenCalled();
expect(fixture.input.onSettled).not.toHaveBeenCalled();
expect(mocks.completeAfterTurn).not.toHaveBeenCalled();
});
it("restores the rewound in-memory branch when settlement fails", async () => {
const sessionManager = SessionManager.inMemory();
const promptId = sessionManager.appendMessage({
@@ -1,5 +1,4 @@
/** Settles the provider stream and completes the post-turn lifecycle phase. */
import { isRunnerAbortError } from "../abort.js";
import { log } from "../logger.js";
import { joinWithRunLivenessDeadline, RUN_LIVENESS_JOIN_TIMEOUT_MS } from "./abortable.js";
import { completeEmbeddedAttemptAfterTurn } from "./attempt-after-turn.js";
@@ -16,8 +15,7 @@ type SharedPhaseInputKeys =
| "attempt"
| "activeSession"
| "sessionManager"
| "sessionLockController"
| "withOwnedSessionWriteLock";
| "withOwnedTranscriptWrite";
// Queued subscription handlers (block-reply delivery, tool events) are
// fire-and-forget during the turn; the pending-events join below is the only
@@ -30,8 +28,7 @@ export async function finalizeEmbeddedAttemptStreamPhase(input: {
attempt: StreamSettleInput["attempt"];
activeSession: StreamSettleInput["activeSession"];
sessionManager: StreamSettleInput["sessionManager"];
sessionLockController: StreamSettleInput["sessionLockController"];
withOwnedSessionWriteLock: StreamSettleInput["withOwnedSessionWriteLock"];
withOwnedTranscriptWrite: StreamSettleInput["withOwnedTranscriptWrite"];
waitForPendingEvents: () => Promise<void>;
repairedRejectedThinkingReplay: boolean;
getRunAbortDeadlineAtMs: () => number;
@@ -51,7 +48,7 @@ export async function finalizeEmbeddedAttemptStreamPhase(input: {
>;
afterTurn: Omit<AfterTurnInput, SharedPhaseInputKeys | "state">;
}): Promise<{ sessionIdUsed: string; sessionFileUsed?: string }> {
const { activeSession, sessionManager, sessionLockController, withOwnedSessionWriteLock } = input;
const { activeSession, sessionManager, withOwnedTranscriptWrite } = input;
await joinWithRunLivenessDeadline({
joinWork: input.waitForPendingEvents,
@@ -67,7 +64,7 @@ export async function finalizeEmbeddedAttemptStreamPhase(input: {
const beforeAgentFinalizeRevisionEntryId = input.getBeforeAgentFinalizeRevisionEntryId();
let rewoundBeforeAgentFinalizeRevision = false;
if (beforeAgentFinalizeRevisionReason && beforeAgentFinalizeRevisionEntryId) {
await withOwnedSessionWriteLock(() => {
await withOwnedTranscriptWrite(() => {
const rejectedEntry = sessionManager.getEntry(beforeAgentFinalizeRevisionEntryId);
if (rejectedEntry?.type !== "message" || rejectedEntry.message.role !== "assistant") {
throw new Error(
@@ -89,20 +86,6 @@ export async function finalizeEmbeddedAttemptStreamPhase(input: {
if (input.repairedRejectedThinkingReplay && !rewoundBeforeAgentFinalizeRevision) {
activeSession.agent.state.messages = sessionManager.buildSessionContext().messages;
}
try {
await sessionLockController.releaseForPrompt();
} catch (err) {
// An aborted attempt never submits another prompt, so a submission-blocked
// release is expected teardown noise, including its recorded timeout reason.
// Rethrowing would silently starve every agent_end consumer for aborted runs.
const lifecycle = input.settle.readLifecycleState();
const expectedAbortError =
isRunnerAbortError(err) || sessionLockController.isPromptSubmissionBlockedError(err);
if (!lifecycle.aborted || !expectedAbortError) {
throw err;
}
}
const currentState = input.getState();
const streamSettleState = {
promptError: currentState.promptError,
@@ -115,8 +98,7 @@ export async function finalizeEmbeddedAttemptStreamPhase(input: {
attempt: input.attempt,
activeSession,
sessionManager,
sessionLockController,
withOwnedSessionWriteLock,
withOwnedTranscriptWrite,
state: streamSettleState,
...input.settle,
runAbortDeadlineAtMs: input.getRunAbortDeadlineAtMs(),
@@ -130,7 +112,7 @@ export async function finalizeEmbeddedAttemptStreamPhase(input: {
}
} finally {
if (rewoundBeforeAgentFinalizeRevision) {
await withOwnedSessionWriteLock(() => {
await withOwnedTranscriptWrite(() => {
// Settlement classifies the completed attempt from its original
// in-memory messages. Later work always sees the rewound branch.
activeSession.agent.state.messages = sessionManager.buildSessionContext().messages;
@@ -146,8 +128,7 @@ export async function finalizeEmbeddedAttemptStreamPhase(input: {
attempt: input.attempt,
activeSession,
sessionManager,
sessionLockController,
withOwnedSessionWriteLock,
withOwnedTranscriptWrite,
...input.afterTurn,
state: {
promptError: settledStream.promptError,
@@ -494,9 +494,6 @@ describe("prepareEmbeddedAttemptStream", () => {
const markExternalAbort = vi.fn();
const markAborted = vi.fn();
const abortActiveSession = vi.fn(async () => {});
const releaseHeldLockForAbort = vi.fn(
async (_params: { reason?: unknown; terminal: boolean }) => {},
);
const abortState = {
markAborted,
markExternalAbort,
@@ -527,7 +524,6 @@ describe("prepareEmbeddedAttemptStream", () => {
isProbeSession: true,
log: { warn: vi.fn() },
runAbortController,
sessionLockController: { releaseHeldLockForAbort },
state: abortState,
});
externalAbortController.setRunAbort(abortRun);
@@ -558,14 +554,7 @@ describe("prepareEmbeddedAttemptStream", () => {
expect(onAttemptAbort).toHaveBeenCalledOnce();
expect(markAborted).toHaveBeenCalledOnce();
expect(abortActiveSession).toHaveBeenCalledOnce();
expect(releaseHeldLockForAbort).toHaveBeenCalledOnce();
expect(releaseHeldLockForAbort).toHaveBeenCalledWith({
reason: expect.any(Error),
terminal: true,
});
expect(
isAgentRunSupersededAbortReason(releaseHeldLockForAbort.mock.calls[0]?.[0].reason),
).toBe(true);
expect(isAgentRunSupersededAbortReason(runAbortController.signal.reason)).toBe(true);
expect(operation.result).toEqual({ kind: "failed", code: "run_stalled" });
expect(operation.abortSignal.aborted).toBe(true);
} finally {
@@ -33,7 +33,6 @@ type StreamGuardPhaseInput = Omit<
| "onIdleTimeout"
| "onRejectedThinkingReplayRepaired"
| "session"
| "sessionLockController"
| "sessionManager"
>;
type HistoryPhaseInput = Omit<HistoryInput, "activeSession" | "attempt" | "sessionManager">;
@@ -54,7 +53,6 @@ export async function prepareEmbeddedAttemptStreamRuntime(input: {
activeSession: StreamInput["activeSession"];
sessionManager: HistoryInput["sessionManager"] &
NonNullable<ToolResultFlushInput["sessionManager"]>;
sessionLockController: StreamGuardInput["sessionLockController"];
ownedTranscriptWriteContext: Parameters<typeof withOwnedSessionTranscriptWrites>[0];
runAbortController: AbortController;
externalAbortController: ExternalAbortController;
@@ -86,7 +84,6 @@ export async function prepareEmbeddedAttemptStreamRuntime(input: {
attempt,
session: activeSession,
sessionManager,
sessionLockController: input.sessionLockController,
isYieldDetected: input.lifecycle.isYieldDetected,
onRejectedThinkingReplayRepaired: input.lifecycle.markRejectedThinkingReplayRepaired,
onIdleTimeout: (error) => idleTimeoutTriggerRef.current?.(error),
@@ -123,7 +120,6 @@ export async function prepareEmbeddedAttemptStreamRuntime(input: {
isProbeSession,
log,
runAbortController: input.runAbortController,
sessionLockController: input.sessionLockController,
state: input.abortState,
});
input.externalAbortController.setRunAbort(abortRun);
@@ -26,8 +26,7 @@ function createSettleFixture(overrides?: Partial<SettleInput>): SettleInput {
messages: [],
},
sessionManager,
sessionLockController: {},
withOwnedSessionWriteLock: async (operation: () => unknown) => await operation(),
withOwnedTranscriptWrite: async (operation: () => unknown) => await operation(),
subscription: {
toolMetas: [],
waitForCompactionRetry: async () => {},
@@ -33,7 +33,6 @@ import {
findLatestUncompactedAttemptUsageSnapshot,
resolvePromptCacheTouchTimestamp,
} from "./attempt.context-engine-helpers.js";
import type { createEmbeddedAttemptSessionLockController } from "./attempt.session-lock.js";
import { appendAttemptCacheTtlIfNeeded } from "./attempt.thread-helpers.js";
import {
hasActiveCompactionRetryWork,
@@ -43,14 +42,11 @@ import { selectCompactionTimeoutSnapshot } from "./compaction-timeout.js";
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js";
type EmbeddedAttemptSubscription = ReturnType<typeof subscribeEmbeddedAgentSession>;
type AttemptSessionLockController = Awaited<
ReturnType<typeof createEmbeddedAttemptSessionLockController>
>;
type PromptCacheRetention = Parameters<typeof buildContextEnginePromptCacheInfo>[0]["retention"];
type ToolSearchTargetTranscriptProjections = Parameters<
typeof projectToolSearchTargetTranscriptMessages
>[1];
type WithOwnedSessionWriteLock = <T>(operation: () => Promise<T> | T) => Promise<T>;
type WithOwnedTranscriptWrite = <T>(operation: () => Promise<T> | T) => Promise<T>;
type StreamSettleResult = {
promptError: unknown;
@@ -72,8 +68,7 @@ export async function settleEmbeddedAttemptStream(input: {
attempt: EmbeddedRunAttemptParams;
activeSession: AgentSession;
sessionManager: SessionManager;
sessionLockController: AttemptSessionLockController;
withOwnedSessionWriteLock: WithOwnedSessionWriteLock;
withOwnedTranscriptWrite: WithOwnedTranscriptWrite;
subscription: EmbeddedAttemptSubscription;
state: {
promptError: unknown;
@@ -253,7 +248,7 @@ export async function settleEmbeddedAttemptStream(input: {
let lastCallUsage: NormalizedUsage | undefined;
let promptCache: EmbeddedRunAttemptResult["promptCache"];
await input.withOwnedSessionWriteLock(async () => {
await input.withOwnedTranscriptWrite(async () => {
const { timedOutDuringCompaction } = input.readLifecycleState();
compactionOccurredThisAttempt = subscription.getCompactionCount() > 0;
appendAttemptCacheTtlIfNeeded({
@@ -22,7 +22,6 @@ import {
} from "../thinking.js";
import { wrapStreamFnWithDiagnosticModelCallEvents } from "./attempt.model-diagnostic-events.js";
import { resolveUnknownToolGuardThreshold } from "./attempt.run-decisions.js";
import type { createEmbeddedAttemptSessionLockController } from "./attempt.session-lock.js";
import {
createYieldAbortedResponse,
isSessionsYieldAbortReason,
@@ -51,10 +50,6 @@ import type { EmbeddedRunAttemptParams } from "./types.js";
type CacheTrace = ReturnType<typeof createCacheTrace>;
type AnthropicPayloadLogger = ReturnType<typeof createAnthropicPayloadLogger>;
type AttemptSessionLockController = Awaited<
ReturnType<typeof createEmbeddedAttemptSessionLockController>
>;
export function installEmbeddedAttemptStreamGuards(input: {
attempt: EmbeddedRunAttemptParams;
session: AgentSession;
@@ -64,7 +59,6 @@ export function installEmbeddedAttemptStreamGuards(input: {
systemPromptText: string;
transcriptPolicy: TranscriptPolicy;
sessionManager: SessionManager | undefined;
sessionLockController: AttemptSessionLockController;
isOpenAIResponsesApi: boolean;
replayAllowedToolNames: Set<string>;
liveAllowedToolNames: Set<string>;
@@ -138,7 +132,6 @@ export function installEmbeddedAttemptStreamGuards(input: {
});
if (repair.repaired) {
input.onRejectedThinkingReplayRepaired();
input.sessionLockController.refreshAfterOwnedSessionWrite();
return;
}
log.warn(
@@ -2,7 +2,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createEmbeddedAttemptRunAbort } from "./attempt-abort.js";
import { prepareEmbeddedAttemptTimeout } from "./attempt-timeout-prepare.js";
import { createEmbeddedAttemptSessionLockController } from "./attempt.session-lock.js";
function createTimeoutHarness(options?: { pendingCompaction?: boolean; timeoutMs?: number }) {
const state = {
@@ -67,26 +66,21 @@ describe("prepareEmbeddedAttemptTimeout", () => {
harness.timeout.clearTimers();
});
it("preserves the built-in deadline reason through a late prompt handoff", async () => {
const sessionLockController = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
it("propagates the built-in deadline reason", async () => {
const runAbortController = new AbortController();
const abortRun = createEmbeddedAttemptRunAbort({
abortActiveSession: vi.fn(async () => {}),
activeSession: { abortCompaction: vi.fn(), isCompacting: false },
attempt: {
runId: "run-deadline-handoff",
runId: "run-deadline",
sessionFile: "agent:main:main",
sessionId: "session-deadline-handoff",
sessionId: "session-deadline",
sessionKey: "agent:main:main",
},
getQueueHandle: () => undefined,
isProbeSession: true,
log: { warn: vi.fn() },
runAbortController,
sessionLockController,
state: {
markAborted: vi.fn(),
markTimedOut: vi.fn(),
@@ -96,8 +90,8 @@ describe("prepareEmbeddedAttemptTimeout", () => {
});
const timeout = prepareEmbeddedAttemptTimeout({
attempt: {
runId: "run-deadline-handoff",
sessionId: "session-deadline-handoff",
runId: "run-deadline",
sessionId: "session-deadline",
timeoutMs: 100,
},
activeSession: { isCompacting: false, isStreaming: false },
@@ -111,13 +105,10 @@ describe("prepareEmbeddedAttemptTimeout", () => {
await vi.advanceTimersByTimeAsync(100);
const timeoutReason = runAbortController.signal.reason;
expect(timeoutReason).toEqual(
expect(runAbortController.signal.reason).toEqual(
expect.objectContaining({ name: "TimeoutError", message: "request timed out" }),
);
await expect(sessionLockController.releaseForPrompt()).rejects.toBe(timeoutReason);
timeout.clearTimers();
await sessionLockController.dispose();
});
it("grants one compaction grace window before aborting", async () => {
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import { prepareEmbeddedAttemptTranscriptLifecycle } from "./attempt-transcript-lifecycle-prepare.js";
describe("prepareEmbeddedAttemptTranscriptLifecycle", () => {
it("carries the admitted writer fence into nested transcript writes", async () => {
const externalAbortController = {
arm: vi.fn(),
throwIfFiredAfterPrepCleanup: vi.fn(async () => undefined),
};
const prepared = await prepareEmbeddedAttemptTranscriptLifecycle({
attempt: {
config: {},
runId: "run-a",
sessionFile: "sqlite:session-a",
sessionId: "session-a",
sessionKey: "agent:main:test",
sessionTarget: {
agentId: "main",
expectedLifecycleRevision: "revision-a",
expectedWriterRunId: "run-a",
sessionId: "session-a",
sessionKey: "agent:main:test",
storePath: "/tmp/openclaw.sqlite",
},
},
externalAbortController,
});
expect(prepared.ownedTranscriptWriteContext.sessionTarget).toMatchObject({
expectedLifecycleRevision: "revision-a",
expectedWriterRunId: "run-a",
});
await expect(prepared.withOwnedTranscriptWrite(async () => "done")).resolves.toBe("done");
expect(externalAbortController.arm).toHaveBeenCalledOnce();
expect(externalAbortController.throwIfFiredAfterPrepCleanup).toHaveBeenCalledTimes(2);
await prepared.transcriptLifecycle.dispose();
});
});
@@ -0,0 +1,84 @@
/** Prepares the admitted writer context and teardown tracker for one attempt. */
import {
type OwnedSessionTranscriptWriteContext,
withOwnedSessionTranscriptWrites,
} from "../../../config/sessions/transcript-write-context.js";
import { resolveAgentRunSessionTarget } from "../../run-session-target.js";
import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js";
import { createEmbeddedAttemptTranscriptLifecycle } from "./attempt-transcript-lifecycle.js";
import type { EmbeddedRunAttemptParams } from "./types.js";
type WithOwnedTranscriptWrite = <T>(operation: () => Promise<T> | T) => Promise<T>;
export async function prepareEmbeddedAttemptTranscriptLifecycle(input: {
attempt: Pick<
EmbeddedRunAttemptParams,
| "abortSignal"
| "config"
| "runId"
| "sessionFile"
| "sessionId"
| "sessionKey"
| "sessionTarget"
>;
externalAbortController: {
arm: () => void;
throwIfFiredAfterPrepCleanup: () => Promise<void>;
};
}): Promise<{
compactionTimeoutMs: number;
ownedTranscriptWriteContext: OwnedSessionTranscriptWriteContext;
transcriptLifecycle: ReturnType<typeof createEmbeddedAttemptTranscriptLifecycle>;
withOwnedTranscriptWrite: WithOwnedTranscriptWrite;
}> {
const { attempt, externalAbortController } = input;
const sessionTarget = await resolveAgentRunSessionTarget({
agentId: attempt.sessionTarget?.agentId,
config: attempt.config,
missingSessionKey: "resolve-existing",
sessionFile: attempt.sessionFile,
sessionId: attempt.sessionId,
sessionKey: attempt.sessionKey,
sessionTarget: attempt.sessionTarget,
});
await externalAbortController.throwIfFiredAfterPrepCleanup();
const transcriptLifecycle = createEmbeddedAttemptTranscriptLifecycle({
runId: attempt.runId,
sessionId: attempt.sessionId,
});
const fencedSessionTarget = {
...sessionTarget,
...(attempt.sessionTarget?.expectedLifecycleRevision !== undefined
? { expectedLifecycleRevision: attempt.sessionTarget.expectedLifecycleRevision }
: {}),
...(attempt.sessionTarget?.expectedWriterRunId !== undefined
? { expectedWriterRunId: attempt.sessionTarget.expectedWriterRunId }
: {}),
};
const ownedTranscriptWriteContext: OwnedSessionTranscriptWriteContext = {
sessionFile: attempt.sessionFile,
sessionKey: attempt.sessionKey,
sessionTarget: fencedSessionTarget,
withTranscriptWrite: (operation) => transcriptLifecycle.withTranscriptWrite(operation),
};
const withOwnedTranscriptWrite: WithOwnedTranscriptWrite = (operation) =>
withOwnedSessionTranscriptWrites(ownedTranscriptWriteContext, async () =>
transcriptLifecycle.withTranscriptWrite(operation),
);
externalAbortController.arm();
try {
await externalAbortController.throwIfFiredAfterPrepCleanup();
} catch (error) {
await transcriptLifecycle.dispose();
throw error;
}
return {
compactionTimeoutMs: resolveCompactionTimeoutMs(attempt.config),
ownedTranscriptWriteContext,
transcriptLifecycle,
withOwnedTranscriptWrite,
};
}
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { createEmbeddedAttemptTranscriptLifecycle } from "./attempt-transcript-lifecycle.js";
describe("createEmbeddedAttemptTranscriptLifecycle", () => {
it("drains admitted transcript writes before cleanup continues", async () => {
const lifecycle = createEmbeddedAttemptTranscriptLifecycle({
runId: "run-a",
sessionId: "session-a",
});
let releaseWrite = () => {};
const writeGate = new Promise<void>((resolve) => {
releaseWrite = resolve;
});
const write = lifecycle.withTranscriptWrite(async () => {
await writeGate;
});
const cleanup = lifecycle.beginCleanup();
let cleanupSettled = false;
void cleanup.then(() => {
cleanupSettled = true;
});
await Promise.resolve();
expect(cleanupSettled).toBe(false);
releaseWrite();
await expect(write).resolves.toBeUndefined();
await expect(cleanup).resolves.toBeUndefined();
await expect(lifecycle.withTranscriptWrite(() => undefined)).rejects.toThrow(
"attempt cleanup started before transcript write",
);
});
it("drains fire-and-forget nested writes before admitting the next writer", async () => {
const lifecycle = createEmbeddedAttemptTranscriptLifecycle({});
const order: string[] = [];
await lifecycle.withTranscriptWrite(() => {
order.push("outer");
void lifecycle.withTranscriptWrite(async () => {
await Promise.resolve();
order.push("nested");
});
});
await lifecycle.withTranscriptWrite(() => {
order.push("next");
});
expect(order).toEqual(["outer", "nested", "next"]);
});
it("rejects cleanup started from inside a transcript callback", async () => {
const lifecycle = createEmbeddedAttemptTranscriptLifecycle({});
await lifecycle.withTranscriptWrite(async () => {
await expect(lifecycle.beginCleanup()).rejects.toThrow(
"cannot start attempt cleanup inside a transcript write callback",
);
});
});
});
@@ -0,0 +1,210 @@
/** Serializes run-owned transcript callbacks and bounds teardown settlement. */
import { AsyncLocalStorage } from "node:async_hooks";
import { toErrorObject } from "../../../infra/errors.js";
import { log } from "../logger.js";
const TRANSCRIPT_TEARDOWN_BUDGET_MS = 30_000;
type LifecycleOwner = {
active: boolean;
nestedPending: number;
nestedTail: Promise<void>;
pendingOperations: Set<Promise<void>>;
};
export type EmbeddedAttemptTranscriptLifecycle = {
withTranscriptWrite<T>(run: () => Promise<T> | T): Promise<T>;
beginCleanup(): Promise<void>;
dispose(): Promise<void>;
};
export function createEmbeddedAttemptTranscriptLifecycle(params: {
runId?: string;
sessionId?: string;
}): EmbeddedAttemptTranscriptLifecycle {
let cleanupRequested = false;
let disposed = false;
let lifecycle = Promise.resolve();
let cleanupDrain: Promise<void> | undefined;
let disposePromise: Promise<void> | undefined;
let pendingWrites = 0;
let teardownBudgetLogged = false;
const lifecycleOwner = new AsyncLocalStorage<LifecycleOwner>();
const createLifecycleOwner = (): LifecycleOwner => ({
active: true,
nestedPending: 0,
nestedTail: Promise.resolve(),
pendingOperations: new Set(),
});
const drainLifecycleOwner = async (owner: LifecycleOwner): Promise<void> => {
let firstError: Error | undefined;
while (owner.pendingOperations.size > 0) {
const pending = [...owner.pendingOperations];
owner.pendingOperations.clear();
const settled = await Promise.allSettled(pending);
const rejected = settled.find((result) => result.status === "rejected");
if (firstError === undefined && rejected?.status === "rejected") {
firstError = toErrorObject(rejected.reason, "nested transcript write failed");
}
}
if (firstError !== undefined) {
throw firstError;
}
};
const runLifecycleOwner = async <T>(
owner: LifecycleOwner,
run: () => Promise<T> | T,
): Promise<T> => {
let value: T | undefined;
let primaryError: Error | undefined;
try {
value = await lifecycleOwner.run(owner, async () => await run());
} catch (error) {
primaryError = toErrorObject(error, "transcript write failed");
}
let drainError: Error | undefined;
try {
await drainLifecycleOwner(owner);
} catch (error) {
drainError = toErrorObject(error, "nested transcript drain failed");
} finally {
owner.active = false;
}
if (primaryError !== undefined) {
if (
drainError !== undefined &&
drainError !== primaryError &&
primaryError.cause === undefined
) {
try {
primaryError.cause = drainError;
} catch {
// Frozen callback errors remain primary; drain failure is secondary.
}
}
throw primaryError;
}
if (drainError !== undefined) {
throw drainError;
}
return value as T;
};
const serializeLifecycle = async <T>(run: () => Promise<T> | T): Promise<T> => {
const inheritedOwner = lifecycleOwner.getStore();
if (inheritedOwner?.active) {
const waitForPrevious =
inheritedOwner.nestedPending > 0 ? inheritedOwner.nestedTail : Promise.resolve();
inheritedOwner.nestedPending += 1;
const operation = waitForPrevious.then(async () => {
const childOwner = createLifecycleOwner();
return await runLifecycleOwner(childOwner, run);
});
const queueTail = operation.then(
() => undefined,
() => undefined,
);
const propagated = operation.then(() => undefined);
void propagated.catch(() => {});
inheritedOwner.nestedTail = queueTail;
inheritedOwner.pendingOperations.add(propagated);
void queueTail.finally(() => {
inheritedOwner.nestedPending -= 1;
});
return await operation;
}
const previous = lifecycle;
let release!: () => void;
lifecycle = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
const owner = createLifecycleOwner();
try {
return await runLifecycleOwner(owner, run);
} finally {
release();
}
};
const logTeardownBudgetExpiry = (): void => {
if (teardownBudgetLogged) {
return;
}
teardownBudgetLogged = true;
log.error(
`transcript teardown budget expired: runId=${params.runId ?? "unknown"} ` +
`sessionId=${params.sessionId ?? "unknown"} pendingWrites=${pendingWrites} ` +
`timeoutMs=${TRANSCRIPT_TEARDOWN_BUDGET_MS}`,
);
};
const settleWithinTeardownBudget = async (operation: Promise<void>): Promise<void> => {
let timeout: ReturnType<typeof setTimeout> | undefined;
const settled = await Promise.race([
operation.then(() => true),
new Promise<false>((resolve) => {
timeout = setTimeout(() => resolve(false), TRANSCRIPT_TEARDOWN_BUDGET_MS);
timeout.unref?.();
}),
]);
if (timeout) {
clearTimeout(timeout);
}
if (!settled) {
void operation.catch(() => {});
logTeardownBudgetExpiry();
}
};
const beginCleanup = async (): Promise<void> => {
const currentOwner = lifecycleOwner.getStore();
if (currentOwner?.active) {
throw new Error("cannot start attempt cleanup inside a transcript write callback");
}
if (cleanupRequested) {
if (cleanupDrain) {
await cleanupDrain;
}
return;
}
cleanupRequested = true;
cleanupDrain = settleWithinTeardownBudget(serializeLifecycle(() => {}));
await cleanupDrain;
};
return {
withTranscriptWrite: (run) => {
const activeDescendant = lifecycleOwner.getStore()?.active === true;
if ((cleanupRequested || disposed) && !activeDescendant) {
const rejected = Promise.reject(
new Error(
disposed
? "attempt disposed before transcript write"
: "attempt cleanup started before transcript write",
),
);
void rejected.catch(() => {});
return rejected;
}
pendingWrites += 1;
const operation = serializeLifecycle(run);
void operation
.finally(() => {
pendingWrites -= 1;
})
.catch(() => {});
return operation;
},
beginCleanup,
dispose: async () => {
const currentOwner = lifecycleOwner.getStore();
if (currentOwner?.active) {
throw new Error("cannot dispose an attempt from inside a transcript write callback");
}
disposePromise ??= (async () => {
await beginCleanup();
disposed = true;
})();
await disposePromise;
},
};
}
@@ -1,4 +1,4 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { buildAgentRunTerminalOutcomeFromAttempt } from "../../agent-run-terminal-outcome.js";
import {
cleanupTempPaths,
@@ -26,44 +26,6 @@ describe("runEmbeddedAttempt abort races", () => {
tempPaths.length = 0;
});
it("stops before session creation when aborted during eager lock acquisition", async () => {
const abortController = new AbortController();
const prompt = vi.fn(async () => {});
const abortError = new Error("stopped during lock acquisition");
abortError.name = "AbortError";
let markLockRequested!: () => void;
let observedSignal: AbortSignal | undefined;
const lockRequested = new Promise<void>((resolve) => {
markLockRequested = resolve;
});
hoisted.acquireSessionWriteLockMock.mockImplementationOnce(async (params) => {
observedSignal = params.signal;
markLockRequested();
await new Promise<void>((resolve) => {
params.signal?.addEventListener("abort", () => resolve(), { once: true });
});
throw params.signal?.reason;
});
const attempt = createContextEngineAttemptRunner({
contextEngine: createContextEngineBootstrapAndAssemble(),
sessionKey: "agent:main:telegram:direct:123",
tempPaths,
sessionPrompt: prompt,
attemptOverrides: {
abortSignal: abortController.signal,
},
});
await lockRequested;
abortController.abort(abortError);
await expect(attempt).rejects.toBe(abortError);
expect(hoisted.createAgentSessionMock).not.toHaveBeenCalled();
expect(prompt).not.toHaveBeenCalled();
expect(observedSignal).toBe(abortController.signal);
});
it("preserves a run-budget timeout when abort blocks prompt submission", async () => {
let releasePendingEvents!: () => void;
const pendingEvents = new Promise<void>((resolve) => {
@@ -1,118 +0,0 @@
// Coverage for small run-attempt decision helpers.
import { describe, expect, it } from "vitest";
import { resolveCredentialScopedAuthAttemptModelDecision } from "../../runtime-plan/credential-scoped-model.js";
import {
resolveAttemptStreamAuthProfileId,
resolveAttemptToolPolicyMessageProvider,
resolveEmbeddedAttemptSessionWriteLockOptions,
resolveUnknownToolGuardThreshold,
shouldRunLlmOutputHooksForAttempt,
} from "./attempt.run-decisions.js";
describe("resolveEmbeddedAttemptSessionWriteLockOptions", () => {
it("derives the dead-process lease TTL from the compaction timeout", () => {
// Live owners renew until explicit release; this only bounds stale metadata after owner death.
const options = resolveEmbeddedAttemptSessionWriteLockOptions({
config: {},
compactionTimeoutMs: 600_000,
env: {},
});
expect(options.maxHoldMs).toBe(720_000);
});
});
describe("resolveAttemptStreamAuthProfileId", () => {
it("uses only the runtime-forwarded auth profile for stream provenance", () => {
// Raw attempt authProfileId may be a session selection detail; stream
// provenance should only expose the runtime-forwarded profile.
expect(
resolveAttemptStreamAuthProfileId({
authProfileId: "openai:raw-session-profile",
runtimePlan: {
auth: {
forwardedAuthProfileId: "openai:forwarded-profile",
},
} as never,
}),
).toBe("openai:forwarded-profile");
expect(
resolveAttemptStreamAuthProfileId({
authProfileId: "openai:non-forwarded-profile",
runtimePlan: {
auth: {},
} as never,
}),
).toBeUndefined();
});
describe("resolveCredentialScopedAuthAttemptModelDecision", () => {
const resolveDecision = (
plan: Record<string, unknown>,
requestedProfileId?: string,
providerUsesProfileScopedModelMetadata = false,
) =>
resolveCredentialScopedAuthAttemptModelDecision({
attempt: { kind: "implicit", plan } as never,
priorProfileAttempted: false,
requestedProfileId,
providerUsesProfileScopedModelMetadata,
});
it("materializes route-less plans when a provider profile scopes model metadata", () => {
expect(resolveDecision({ forwardedAuthProfileId: "github-copilot:work" })).toMatchObject({
forceResolve: false,
shouldMaterialize: true,
});
expect(resolveDecision({}, "github-copilot:requested").shouldMaterialize).toBe(true);
expect(resolveDecision({ selectedAuthMode: "api-key" }, undefined, true)).toMatchObject({
forceResolve: false,
shouldMaterialize: true,
authRequirement: "api-key",
});
expect(
resolveDecision({ selectedAuthMode: "api-key" }, undefined, false).shouldMaterialize,
).toBe(false);
expect(resolveDecision({}).shouldMaterialize).toBe(false);
});
});
});
describe("resolveAttemptToolPolicyMessageProvider", () => {
it("prefers explicit tool-policy provider over transport channel", () => {
expect(
resolveAttemptToolPolicyMessageProvider({
messageChannel: "discord",
messageProvider: "discord-voice",
}),
).toBe("discord-voice");
});
it("falls back to message channel when provider is omitted", () => {
expect(resolveAttemptToolPolicyMessageProvider({ messageChannel: "discord" })).toBe("discord");
});
});
describe("shouldRunLlmOutputHooksForAttempt", () => {
it("skips llm_output after before_agent_run blocks before model submission", () => {
expect(shouldRunLlmOutputHooksForAttempt({ promptErrorSource: "hook:before_agent_run" })).toBe(
false,
);
expect(shouldRunLlmOutputHooksForAttempt({ promptErrorSource: "prompt" })).toBe(true);
expect(shouldRunLlmOutputHooksForAttempt({ promptErrorSource: null })).toBe(true);
});
});
describe("resolveUnknownToolGuardThreshold", () => {
it("returns the default threshold when no loop-detection config is provided", () => {
expect(resolveUnknownToolGuardThreshold(undefined)).toBe(10);
expect(resolveUnknownToolGuardThreshold({})).toBe(10);
});
it("stays on even when tools.loopDetection.enabled is false", () => {
// Unknown-tool guard is a model-safety circuit, separate from configurable
// repeated-tool loop detection.
expect(resolveUnknownToolGuardThreshold({ enabled: false })).toBe(10);
});
});
@@ -1,30 +1,9 @@
/**
* Resolves per-attempt runtime decisions from config and channel context.
*/
import type { OpenClawConfig } from "../../../config/config.js";
import {
resolveSessionLockMaxHoldFromTimeout,
resolveSessionWriteLockOptions,
} from "../../session-write-lock.js";
import { UNKNOWN_TOOL_THRESHOLD } from "../../tool-loop-detection.js";
import type { EmbeddedRunAttemptParams } from "./types.js";
/** Builds the session write-lock timing for a live embedded attempt. */
export function resolveEmbeddedAttemptSessionWriteLockOptions(params: {
config?: OpenClawConfig;
compactionTimeoutMs: number;
env?: NodeJS.ProcessEnv;
}): { timeoutMs: number; staleMs: number; maxHoldMs: number } {
// maxHoldMs is the SQLite lease TTL for dead-process reclamation, not a live-owner watchdog.
// A live process renews the lease until teardown releases it.
return resolveSessionWriteLockOptions(params.config, {
env: params.env,
maxHoldMsFallback: resolveSessionLockMaxHoldFromTimeout({
timeoutMs: params.compactionTimeoutMs,
}),
});
}
/**
* Returns the auth profile id that should be attached to model-stream
* provenance. Only runtime-forwarded ids are exposed; raw request auth ids can
@@ -1,186 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
loadTranscriptEvents,
upsertSessionEntry,
} from "../../../config/sessions/session-accessor.js";
import { withOwnedSessionTranscriptWrites } from "../../../config/sessions/transcript-write-context.js";
import { withOpenClawTestState } from "../../../test-utils/openclaw-test-state.js";
import { SessionWriteLockStaleError } from "../../session-write-lock-error.js";
import { SessionManager } from "../../sessions/session-manager.js";
import { createEmbeddedAttemptSessionLockController } from "./attempt.session-lock.js";
describe("attempt session-lock teardown budgets", () => {
it("bounds cleanup acquisition and releases before a started writer settles", async () => {
vi.useFakeTimers();
try {
let markWriteStarted!: () => void;
const writeStarted = new Promise<void>((resolve) => {
markWriteStarted = resolve;
});
let resumeWrite!: () => void;
const writeBlocked = new Promise<void>((resolve) => {
resumeWrite = resolve;
});
const release = vi.fn(async () => undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release })),
lockOptions: { sessionFile: "agent:main:main" },
runId: "cleanup-budget-run",
sessionId: "cleanup-budget-session",
});
const write = controller.withSessionWriteLock(async () => {
markWriteStarted();
await writeBlocked;
});
await writeStarted;
const cleanupAcquisition = controller.acquireForCleanup();
await vi.advanceTimersByTimeAsync(29_999);
expect(release).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
const cleanupLock = await cleanupAcquisition;
expect(release).toHaveBeenCalledOnce();
expect(controller.hasSessionTakeover()).toBe(true);
await cleanupLock.release();
await expect(controller.dispose()).resolves.toBeUndefined();
resumeWrite();
await expect(write).resolves.toBeUndefined();
} finally {
vi.useRealTimers();
}
});
it("force-releases a started writer at the teardown budget and fences its late persist", async () => {
vi.useFakeTimers();
try {
await withOpenClawTestState({ label: "attempt-session-lock-late-persist" }, async (state) => {
const target = {
agentId: "main",
sessionId: "late-persist-session",
sessionKey: "agent:main:late-persist-session",
storePath: state.path("sessions.json"),
};
await upsertSessionEntry(target, { sessionId: target.sessionId, updatedAt: 1 });
const sessionManager = SessionManager.open(target, state.workspaceDir);
sessionManager.appendModelChange("test", "before");
const before = await loadTranscriptEvents(target);
const leaseError = new SessionWriteLockStaleError({
lockPath: `sqlite:session-write:${target.sessionKey}`,
owner: "released teardown lease",
staleReasons: ["lease-lost"],
});
let owned = true;
const release = vi.fn(async () => {
owned = false;
});
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({
assertOwned: () => {
if (!owned) {
throw leaseError;
}
},
release,
})),
lockOptions: { sessionFile: target.sessionKey },
runId: "late-persist-run",
sessionId: target.sessionId,
});
let markWriteStarted!: () => void;
const writeStarted = new Promise<void>((resolve) => {
markWriteStarted = resolve;
});
let resumeWrite!: () => void;
const writeBlocked = new Promise<void>((resolve) => {
resumeWrite = resolve;
});
const write = withOwnedSessionTranscriptWrites(
{
sessionFile: target.sessionKey,
sessionKey: target.sessionKey,
sessionTarget: target,
assertOwned: () => controller.assertOwned(),
withSessionWriteLock: (run, options) => controller.withSessionWriteLock(run, options),
},
async () =>
await controller.withSessionWriteLock(async () => {
markWriteStarted();
await writeBlocked;
sessionManager.appendModelChange("test", "too-late");
}),
);
await writeStarted;
const disposal = controller.dispose();
await vi.advanceTimersByTimeAsync(5_000 + 29_999);
expect(release).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await expect(disposal).resolves.toBeUndefined();
expect(release).toHaveBeenCalledOnce();
resumeWrite();
await expect(write).rejects.toBe(leaseError);
await expect(loadTranscriptEvents(target)).resolves.toEqual(before);
});
} finally {
vi.useRealTimers();
}
});
it("lets a started persist settle within the teardown budget before releasing", async () => {
await withOpenClawTestState(
{ label: "attempt-session-lock-settled-persist" },
async (state) => {
const target = {
agentId: "main",
sessionId: "settled-persist-session",
sessionKey: "agent:main:settled-persist-session",
storePath: state.path("sessions.json"),
};
await upsertSessionEntry(target, { sessionId: target.sessionId, updatedAt: 1 });
const sessionManager = SessionManager.open(target, state.workspaceDir);
sessionManager.appendModelChange("test", "before");
const release = vi.fn(async () => undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ assertOwned: () => undefined, release })),
lockOptions: { sessionFile: target.sessionKey },
});
let markWriteStarted!: () => void;
const writeStarted = new Promise<void>((resolve) => {
markWriteStarted = resolve;
});
let resumeWrite!: () => void;
const writeBlocked = new Promise<void>((resolve) => {
resumeWrite = resolve;
});
const write = withOwnedSessionTranscriptWrites(
{
sessionFile: target.sessionKey,
sessionKey: target.sessionKey,
sessionTarget: target,
assertOwned: () => controller.assertOwned(),
withSessionWriteLock: (run, options) => controller.withSessionWriteLock(run, options),
},
async () =>
await controller.withSessionWriteLock(async () => {
markWriteStarted();
await writeBlocked;
sessionManager.appendModelChange("test", "within-budget");
}),
);
await writeStarted;
const disposal = controller.dispose();
await Promise.resolve();
expect(release).not.toHaveBeenCalled();
resumeWrite();
await Promise.all([write, disposal]);
expect(release).toHaveBeenCalledOnce();
await expect(loadTranscriptEvents(target)).resolves.toContainEqual(
expect.objectContaining({ modelId: "within-budget", type: "model_change" }),
);
},
);
});
});
@@ -1,936 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { SessionWriteLockStaleError } from "../../session-write-lock-error.js";
import {
createEmbeddedAttemptSessionLockController,
EmbeddedAttemptSessionTakeoverError,
installPromptSubmissionLockRelease,
} from "./attempt.session-lock.js";
describe("createEmbeddedAttemptSessionLockController", () => {
it("reloads SQLite transcript state after prompt-time writers finish", async () => {
const reloadPromptReleasedSessionFile = vi.fn(async () => undefined);
const acquireSessionWriteLock = vi.fn(async () => ({ release: async () => undefined }));
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock,
lockOptions: { sessionFile: "agent:main:main" },
reloadPromptReleasedSessionFile,
});
await controller.reacquireAfterPrompt();
expect(reloadPromptReleasedSessionFile).toHaveBeenCalledOnce();
expect(acquireSessionWriteLock).toHaveBeenCalledWith({
sessionFile: "agent:main:main",
targetKind: "session-key",
});
});
it("serializes the complete SQLite write callbacks", async () => {
let releaseFirst!: () => void;
const firstBlocked = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const events: string[] = [];
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
const first = controller.withSessionWriteLock(async () => {
events.push("first:start");
await firstBlocked;
events.push("first:end");
});
const second = controller.withSessionWriteLock(() => {
events.push("second");
});
await vi.waitFor(() => expect(events).toEqual(["first:start"]));
releaseFirst();
await Promise.all([first, second]);
expect(events).toEqual(["first:start", "first:end", "second"]);
});
it("fences transcript writes after the SQLite session lease is lost", async () => {
const leaseError = new SessionWriteLockStaleError({
lockPath: "sqlite:session-write:agent:main:main",
owner: "replacement",
staleReasons: ["lease-lost"],
});
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({
assertOwned: () => {
throw leaseError;
},
release: async () => undefined,
})),
lockOptions: { sessionFile: "agent:main:main" },
});
await expect(controller.withSessionWriteLock(() => undefined)).rejects.toBe(leaseError);
expect(controller.hasSessionTakeover()).toBe(true);
});
it("blocks prompt submission after the SQLite session lease is lost", async () => {
const leaseError = new SessionWriteLockStaleError({
lockPath: "sqlite:session-write:agent:main:main",
owner: "replacement",
staleReasons: ["lease-lost"],
});
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({
assertOwned: () => {
throw leaseError;
},
release: async () => undefined,
})),
lockOptions: { sessionFile: "agent:main:main" },
});
await expect(controller.releaseForPrompt()).rejects.toBe(leaseError);
expect(controller.hasSessionTakeover()).toBe(true);
});
it("allows nested writes to reuse the active lifecycle owner", async () => {
const events: string[] = [];
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
await controller.withSessionWriteLock(async () => {
events.push("outer:start");
await controller.withSessionWriteLock(() => {
events.push("nested");
});
events.push("outer:end");
});
expect(events).toEqual(["outer:start", "nested", "outer:end"]);
});
it("queues async descendants that resume after their lifecycle owner exits", async () => {
let resumeDescendant!: () => void;
const descendantBlocked = new Promise<void>((resolve) => {
resumeDescendant = resolve;
});
let releaseSecond!: () => void;
const secondBlocked = new Promise<void>((resolve) => {
releaseSecond = resolve;
});
const events: string[] = [];
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
let descendant!: Promise<void>;
await controller.withSessionWriteLock(() => {
descendant = (async () => {
await descendantBlocked;
await controller.withSessionWriteLock(() => {
events.push("descendant");
});
})();
});
const second = controller.withSessionWriteLock(async () => {
events.push("second:start");
await secondBlocked;
events.push("second:end");
});
await vi.waitFor(() => expect(events).toEqual(["second:start"]));
resumeDescendant();
await Promise.resolve();
expect(events).toEqual(["second:start"]);
releaseSecond();
await Promise.all([second, descendant]);
expect(events).toEqual(["second:start", "second:end", "descendant"]);
});
it("keeps the lifecycle held until detached nested writes settle", async () => {
let releaseNested!: () => void;
const nestedBlocked = new Promise<void>((resolve) => {
releaseNested = resolve;
});
const events: string[] = [];
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
const outer = controller.withSessionWriteLock(() => {
void controller.withSessionWriteLock(async () => {
events.push("nested:start");
await nestedBlocked;
events.push("nested:end");
});
});
const second = controller.withSessionWriteLock(() => {
events.push("second");
});
await vi.waitFor(() => expect(events).toEqual(["nested:start"]));
releaseNested();
await Promise.all([outer, second]);
expect(events).toEqual(["nested:start", "nested:end", "second"]);
});
it("serializes sibling nested writes in submission order", async () => {
let releaseFirst!: () => void;
const firstBlocked = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const events: string[] = [];
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
await controller.withSessionWriteLock(async () => {
const first = controller.withSessionWriteLock(async () => {
events.push("first:start");
await firstBlocked;
events.push("first:end");
});
const second = controller.withSessionWriteLock(() => {
events.push("second");
});
await vi.waitFor(() => expect(events).toEqual(["first:start"]));
releaseFirst();
await Promise.all([first, second]);
});
expect(events).toEqual(["first:start", "first:end", "second"]);
});
it("propagates failures from detached nested writes", async () => {
const nestedError = new Error("nested write failed");
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
await expect(
controller.withSessionWriteLock(() => {
void controller.withSessionWriteLock(() => {
throw nestedError;
});
}),
).rejects.toBe(nestedError);
});
it("waits for every detached nested write before propagating failure", async () => {
const nestedError = new Error("nested write failed");
let releaseSlowWrite!: () => void;
const slowWriteBlocked = new Promise<void>((resolve) => {
releaseSlowWrite = resolve;
});
const slowWriteStarted = vi.fn();
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
const completion = controller.withSessionWriteLock(() => {
void controller.withSessionWriteLock(() => {
throw nestedError;
});
void controller.withSessionWriteLock(async () => {
slowWriteStarted();
await slowWriteBlocked;
});
});
let settled = false;
void completion.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await vi.waitFor(() => expect(slowWriteStarted).toHaveBeenCalledOnce());
expect(settled).toBe(false);
releaseSlowWrite();
await expect(completion).rejects.toBe(nestedError);
});
it("preserves the callback error when a handled nested write also failed", async () => {
const primaryError = new Error("outer write failed");
const nestedError = new Error("nested write failed");
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
await expect(
controller.withSessionWriteLock(async () => {
await controller
.withSessionWriteLock(() => {
throw nestedError;
})
.catch(() => undefined);
throw primaryError;
}),
).rejects.toBe(primaryError);
expect(primaryError.cause).toBe(nestedError);
});
it("preserves a frozen callback error when nested drain also failed", async () => {
const primaryError = new Error("outer write failed");
Object.freeze(primaryError);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
await expect(
controller.withSessionWriteLock(async () => {
await controller
.withSessionWriteLock(() => {
throw new Error("nested write failed");
})
.catch(() => undefined);
throw primaryError;
}),
).rejects.toBe(primaryError);
});
it("does not attach a propagated nested error as its own cause", async () => {
const nestedError = new Error("nested write failed");
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
await expect(
controller.withSessionWriteLock(async () => {
await controller.withSessionWriteLock(() => {
throw nestedError;
});
}),
).rejects.toBe(nestedError);
expect(nestedError.cause).toBeUndefined();
});
it("terminally fences writes after a generic prompt reload failure", async () => {
const reloadError = new Error("reload failed");
const run = vi.fn();
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
reloadPromptReleasedSessionFile: () => {
throw reloadError;
},
});
await expect(controller.reacquireAfterPrompt()).rejects.toBe(reloadError);
await expect(controller.withSessionWriteLock(run)).rejects.toBe(reloadError);
await controller.releaseForPrompt();
await expect(controller.reacquireAfterPrompt()).rejects.toBe(reloadError);
await expect(controller.acquireForCleanup()).resolves.toBeDefined();
expect(run).not.toHaveBeenCalled();
expect(controller.hasSessionTakeover()).toBe(false);
});
it("marks only takeover reload failures as session takeover", async () => {
const takeoverError = new EmbeddedAttemptSessionTakeoverError("agent:main:main");
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
reloadPromptReleasedSessionFile: () => {
throw takeoverError;
},
});
await expect(controller.reacquireAfterPrompt()).rejects.toBe(takeoverError);
expect(controller.hasSessionTakeover()).toBe(true);
});
it("does not wait on a stalled reload after the disposal timeout", async () => {
vi.useFakeTimers();
try {
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
reloadPromptReleasedSessionFile: async () => await new Promise<void>(() => {}),
});
await controller.releaseForPrompt();
void controller.reacquireAfterPrompt();
await Promise.resolve();
const disposal = controller.dispose();
await vi.advanceTimersByTimeAsync(5_000);
await expect(disposal).resolves.toBeUndefined();
await expect(controller.releaseForPrompt()).rejects.toThrow(
"attempt disposed before prompt submission",
);
await expect(controller.reacquireAfterPrompt()).resolves.toBeUndefined();
} finally {
vi.useRealTimers();
}
});
it("keeps queued writes inside the disposal timeout when prompt reload stalls", async () => {
vi.useFakeTimers();
try {
let markReloadStarted!: () => void;
const reloadStarted = new Promise<void>((resolve) => {
markReloadStarted = resolve;
});
let finishReload!: () => void;
const reloadBlocked = new Promise<void>((resolve) => {
finishReload = resolve;
});
const release = vi.fn(async () => undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release })),
lockOptions: { sessionFile: "agent:main:main" },
reloadPromptReleasedSessionFile: async () => {
markReloadStarted();
await reloadBlocked;
},
});
await controller.releaseForPrompt();
const reacquire = controller.reacquireAfterPrompt();
await reloadStarted;
const writeCallback = vi.fn();
const queuedWrite = controller.withSessionWriteLock(writeCallback);
const queuedWriteOutcome = queuedWrite.then(
() => undefined,
(error: unknown) => error,
);
const disposal = controller.dispose();
await vi.advanceTimersByTimeAsync(5_000);
await expect(disposal).resolves.toBeUndefined();
expect(release).toHaveBeenCalledOnce();
expect(writeCallback).not.toHaveBeenCalled();
finishReload();
await expect(reacquire).resolves.toBeUndefined();
await expect(queuedWriteOutcome).resolves.toEqual(
expect.objectContaining({ message: "attempt disposed before transcript write" }),
);
expect(writeCallback).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it("rejects disposal from inside a write callback", async () => {
const release = vi.fn(async () => undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release })),
lockOptions: { sessionFile: "agent:main:main" },
});
await controller.withSessionWriteLock(async () => {
await expect(controller.dispose()).rejects.toThrow(
"cannot dispose an attempt from inside a transcript write callback",
);
expect(release).not.toHaveBeenCalled();
});
expect(release).not.toHaveBeenCalled();
await controller.dispose();
expect(release).toHaveBeenCalledOnce();
});
it("rejects cleanup from inside a write callback", async () => {
const release = vi.fn(async () => undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release })),
lockOptions: { sessionFile: "agent:main:main" },
});
await controller.withSessionWriteLock(async () => {
await expect(controller.acquireForCleanup()).rejects.toThrow(
"cannot start attempt cleanup inside a transcript write callback",
);
expect(release).not.toHaveBeenCalled();
});
const cleanupLock = await controller.acquireForCleanup();
await expect(controller.withSessionWriteLock(async () => undefined)).rejects.toThrow(
"attempt cleanup started before transcript write",
);
await cleanupLock.release();
expect(release).toHaveBeenCalledOnce();
});
it("keeps cleanup ownership until the granted cleanup lock is released", async () => {
const release = vi.fn(async () => undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release })),
lockOptions: { sessionFile: "agent:main:main" },
});
const cleanupLock = await controller.acquireForCleanup();
let disposeSettled = false;
const disposal = controller.dispose().then(() => {
disposeSettled = true;
});
await Promise.resolve();
expect(disposeSettled).toBe(false);
expect(release).not.toHaveBeenCalled();
await cleanupLock.release();
await disposal;
expect(release).toHaveBeenCalledOnce();
});
it("retries the underlying release when cleanup release fails", async () => {
const releaseError = new Error("release failed");
const release = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(releaseError)
.mockResolvedValueOnce(undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release })),
lockOptions: { sessionFile: "agent:main:main" },
});
const cleanupLock = await controller.acquireForCleanup();
const disposal = controller.dispose();
await expect(cleanupLock.release()).rejects.toBe(releaseError);
await expect(disposal).resolves.toBeUndefined();
expect(release).toHaveBeenCalledTimes(2);
});
it("rejects a second cleanup acquisition without replacing ownership", async () => {
const release = vi.fn(async () => undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release })),
lockOptions: { sessionFile: "agent:main:main" },
});
const cleanupLock = await controller.acquireForCleanup();
await expect(controller.acquireForCleanup()).rejects.toThrow("attempt cleanup already started");
await cleanupLock.release();
await expect(controller.dispose()).resolves.toBeUndefined();
expect(release).toHaveBeenCalledOnce();
});
it("allows cleanup from an async descendant after its write callback settles", async () => {
let resumeDescendant!: () => void;
const descendantBlocked = new Promise<void>((resolve) => {
resumeDescendant = resolve;
});
const release = vi.fn(async () => undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release })),
lockOptions: { sessionFile: "agent:main:main" },
});
let descendant!: Promise<void>;
await controller.withSessionWriteLock(() => {
descendant = (async () => {
await descendantBlocked;
const cleanupLock = await controller.acquireForCleanup();
await cleanupLock.release();
})();
});
resumeDescendant();
await descendant;
expect(release).toHaveBeenCalledOnce();
});
it("lets a started write finish nested writes after disposal begins", async () => {
let resumeOuter!: () => void;
const outerBlocked = new Promise<void>((resolve) => {
resumeOuter = resolve;
});
const nestedWrite = vi.fn();
const release = vi.fn(async () => undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release })),
lockOptions: { sessionFile: "agent:main:main" },
});
const outer = controller.withSessionWriteLock(async () => {
await outerBlocked;
await controller.withSessionWriteLock(nestedWrite);
});
await Promise.resolve();
const disposal = controller.dispose();
resumeOuter();
await Promise.all([outer, disposal]);
expect(nestedWrite).toHaveBeenCalledOnce();
expect(release).toHaveBeenCalledOnce();
});
it("keeps disposal tied to a released prompt until the bounded timeout", async () => {
vi.useFakeTimers();
try {
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
await controller.releaseForPrompt();
let disposed = false;
const disposal = controller.dispose().then(() => {
disposed = true;
});
await vi.advanceTimersByTimeAsync(4_999);
expect(disposed).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await disposal;
expect(disposed).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("settles a released prompt when reacquire arrives after disposal", async () => {
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
await controller.releaseForPrompt();
const disposal = controller.dispose();
await controller.reacquireAfterPrompt();
await expect(disposal).resolves.toBeUndefined();
});
it("keeps a queued next prompt settlement after the prior reacquire finishes", async () => {
vi.useFakeTimers();
try {
let markReloadStarted!: () => void;
const reloadStarted = new Promise<void>((resolve) => {
markReloadStarted = resolve;
});
let finishReload!: () => void;
const reloadBlocked = new Promise<void>((resolve) => {
finishReload = resolve;
});
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
reloadPromptReleasedSessionFile: async () => {
markReloadStarted();
await reloadBlocked;
},
});
await controller.releaseForPrompt();
const firstReacquire = controller.reacquireAfterPrompt();
await reloadStarted;
const secondRelease = controller.releaseForPrompt();
finishReload();
await Promise.all([firstReacquire, secondRelease]);
let disposed = false;
const disposal = controller.dispose().then(() => {
disposed = true;
});
await vi.advanceTimersByTimeAsync(4_999);
expect(disposed).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await disposal;
expect(disposed).toBe(true);
} finally {
vi.useRealTimers();
}
});
it("keeps repeated disposal bounded after abort clears the prompt release", async () => {
vi.useFakeTimers();
try {
let markReloadStarted!: () => void;
const reloadStarted = new Promise<void>((resolve) => {
markReloadStarted = resolve;
});
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
reloadPromptReleasedSessionFile: async () => {
markReloadStarted();
await new Promise<void>(() => {});
},
});
await controller.releaseForPrompt();
void controller.reacquireAfterPrompt();
await reloadStarted;
await controller.releaseHeldLockForAbort();
const firstDisposal = controller.dispose();
const secondDisposal = controller.dispose();
await vi.advanceTimersByTimeAsync(5_000);
await expect(Promise.all([firstDisposal, secondDisposal])).resolves.toEqual([
undefined,
undefined,
]);
} finally {
vi.useRealTimers();
}
});
it("rejects a late SDK prompt handoff after cleanup has started", async () => {
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
await controller.acquireForCleanup();
await expect(controller.releaseForPrompt()).rejects.toThrow(
"attempt cleanup started before prompt submission",
);
});
it("preserves the abort reason for a late SDK prompt handoff", async () => {
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
});
const reason = new Error("cron setup timed out");
reason.name = "TimeoutError";
expect(controller.isPromptSubmissionBlockedError(undefined)).toBe(false);
await controller.releaseHeldLockForAbort({ reason });
expect(controller.isPromptSubmissionBlockedError(reason)).toBe(true);
expect(controller.isPromptSubmissionBlockedError(new Error(reason.message))).toBe(false);
await expect(controller.releaseForPrompt()).rejects.toBe(reason);
});
it("rejects a late SDK prompt handoff after disposal", async () => {
const write = vi.fn();
const release = vi.fn(async () => undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release })),
lockOptions: { sessionFile: "agent:main:main" },
});
await controller.dispose();
await controller.dispose();
await expect(controller.releaseForPrompt()).rejects.toThrow(
"attempt disposed before prompt submission",
);
await expect(controller.withSessionWriteLock(write)).rejects.toThrow(
"attempt disposed before transcript write",
);
expect(write).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledOnce();
});
it("retries direct disposal when the underlying release fails", async () => {
const releaseError = new Error("release failed");
const release = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(releaseError)
.mockResolvedValueOnce(undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release })),
lockOptions: { sessionFile: "agent:main:main" },
});
await expect(controller.dispose()).rejects.toBe(releaseError);
await expect(controller.dispose()).resolves.toBeUndefined();
expect(release).toHaveBeenCalledTimes(2);
});
it("keeps session ownership until an active write callback settles", async () => {
let releaseWrite!: () => void;
const writeBlocked = new Promise<void>((resolve) => {
releaseWrite = resolve;
});
const release = vi.fn(async () => undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release })),
lockOptions: { sessionFile: "agent:main:main" },
});
const write = controller.withSessionWriteLock(async () => await writeBlocked);
await Promise.resolve();
let disposeSettled = false;
const disposal = controller.dispose().then(() => {
disposeSettled = true;
});
await Promise.resolve();
expect(disposeSettled).toBe(false);
expect(release).not.toHaveBeenCalled();
releaseWrite();
await Promise.all([write, disposal]);
expect(release).toHaveBeenCalledOnce();
});
it("reloads after a delayed prompt following a non-terminal sessions_yield abort", async () => {
const reloadPromptReleasedSessionFile = vi.fn();
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
reloadPromptReleasedSessionFile,
});
await controller.releaseHeldLockForAbort({ terminal: false });
await expect(controller.releaseForPrompt()).resolves.toBeUndefined();
await expect(controller.reacquireAfterPrompt()).resolves.toBeUndefined();
expect(reloadPromptReleasedSessionFile).toHaveBeenCalledOnce();
});
it("reloads released transcript state during cleanup after a terminal abort", async () => {
const reloadPromptReleasedSessionFile = vi.fn(async () => undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
reloadPromptReleasedSessionFile,
});
await controller.releaseForPrompt();
await controller.releaseHeldLockForAbort();
const cleanupLock = await controller.acquireForCleanup();
expect(reloadPromptReleasedSessionFile).toHaveBeenCalledOnce();
await cleanupLock.release();
});
it("rejects cleanup acquisition when disposal wins during prompt reload", async () => {
vi.useFakeTimers();
try {
let markReloadStarted!: () => void;
const reloadStarted = new Promise<void>((resolve) => {
markReloadStarted = resolve;
});
let finishReload!: () => void;
const reloadBlocked = new Promise<void>((resolve) => {
finishReload = resolve;
});
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
reloadPromptReleasedSessionFile: async () => {
markReloadStarted();
await reloadBlocked;
},
});
await controller.releaseForPrompt();
const cleanupAcquisition = controller.acquireForCleanup();
const cleanupOutcome = cleanupAcquisition.then(
() => undefined,
(error: unknown) => error,
);
await reloadStarted;
const disposal = controller.dispose();
await vi.advanceTimersByTimeAsync(5_000);
await expect(disposal).resolves.toBeUndefined();
finishReload();
await expect(cleanupOutcome).resolves.toEqual(
expect.objectContaining({ message: "attempt disposed before cleanup" }),
);
} finally {
vi.useRealTimers();
}
});
it("lets cleanup settle a completed prompt when the SDK omits reacquire", async () => {
const reloadPromptReleasedSessionFile = vi.fn(async () => undefined);
const controller = await createEmbeddedAttemptSessionLockController({
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => undefined })),
lockOptions: { sessionFile: "agent:main:main" },
reloadPromptReleasedSessionFile,
});
await controller.releaseForPrompt();
await expect(controller.acquireForCleanup()).resolves.toBeDefined();
expect(reloadPromptReleasedSessionFile).toHaveBeenCalledOnce();
await expect(controller.reacquireAfterPrompt()).resolves.toBeUndefined();
expect(reloadPromptReleasedSessionFile).toHaveBeenCalledOnce();
await expect(controller.releaseForPrompt()).rejects.toThrow(
"attempt cleanup started before prompt submission",
);
});
});
describe("installPromptSubmissionLockRelease", () => {
it("preserves the provider error when prompt settlement also fails", async () => {
const providerError = new Error("provider failed");
const settlementError = new Error("reacquire failed");
const session = {
agent: {
streamFn: vi.fn(async () => {
throw providerError;
}),
},
};
installPromptSubmissionLockRelease({
session,
releaseForPrompt: vi.fn(async () => undefined),
reacquireAfterPrompt: vi.fn(async () => {
throw settlementError;
}),
});
await expect(session.agent.streamFn()).rejects.toBe(providerError);
expect(providerError.cause).toBe(settlementError);
});
it("does not attach a shared provider and settlement error as its own cause", async () => {
const sharedError = new Error("shared failure");
const session = {
agent: {
streamFn: vi.fn(async () => {
throw sharedError;
}),
},
};
installPromptSubmissionLockRelease({
session,
releaseForPrompt: vi.fn(async () => undefined),
reacquireAfterPrompt: vi.fn(async () => {
throw sharedError;
}),
});
await expect(session.agent.streamFn()).rejects.toBe(sharedError);
expect(sharedError.cause).toBeUndefined();
});
it("preserves an undefined prompt settlement rejection", async () => {
const session = { agent: { streamFn: vi.fn(async () => "ok") } };
const undefinedRejection = new Promise<void>((_resolve, reject) => {
queueMicrotask(() => {
Reflect.apply(reject, undefined, [undefined]);
});
});
installPromptSubmissionLockRelease({
session,
releaseForPrompt: vi.fn(async () => undefined),
reacquireAfterPrompt: vi.fn(() => undefinedRejection),
});
await expect(session.agent.streamFn()).rejects.toBeUndefined();
});
it("preserves a frozen provider error when prompt settlement also fails", async () => {
const providerError = new Error("frozen provider failure");
Object.freeze(providerError);
const session = {
agent: {
streamFn: vi.fn(async () => {
throw providerError;
}),
},
};
installPromptSubmissionLockRelease({
session,
releaseForPrompt: vi.fn(async () => undefined),
reacquireAfterPrompt: vi.fn(async () => {
throw new Error("settlement failed");
}),
});
await expect(session.agent.streamFn()).rejects.toBe(providerError);
});
});
@@ -1,654 +0,0 @@
/** Coordinates embedded-attempt lifecycle around SQLite-owned transcript writes. */
import { AsyncLocalStorage } from "node:async_hooks";
import type {
OwnedSessionTranscriptCacheSnapshot,
OwnedSessionTranscriptWriteOptions,
SessionTranscriptWriteLockTarget,
} from "../../../config/sessions/transcript-write-context.js";
import { withOwnedSessionTranscriptWrites } from "../../../config/sessions/transcript-write-context.js";
import { isSessionWriteLockAcquireError } from "../../session-write-lock-error.js";
import type { acquireSessionWriteLock } from "../../session-write-lock.js";
import { log } from "../logger.js";
type SessionLock = Awaited<ReturnType<typeof acquireSessionWriteLock>>;
type AcquireSessionWriteLock = typeof acquireSessionWriteLock;
type SessionKeyLockOptions = Extract<
Parameters<AcquireSessionWriteLock>[0],
{ targetKind: "session-key" }
>;
type LockOptions = Omit<SessionKeyLockOptions, "targetKind"> & {
targetKind?: "session-key";
};
const PROMPT_DISPOSE_SETTLE_TIMEOUT_MS = 5_000;
// Teardown must release the process-owned lease even when an admitted writer never settles.
const SESSION_LOCK_TEARDOWN_BUDGET_MS = 30_000;
function createPromptSubmissionAbortError(reason: unknown): Error {
if (reason instanceof Error) {
return reason;
}
return reason === undefined
? new Error("attempt aborted before prompt submission")
: new Error("attempt aborted before prompt submission", { cause: reason });
}
export type EmbeddedAttemptSessionFileOwner = {
sessionFileKey: string;
release(): void;
};
/** Session lanes and SQLite writer queues already serialize this identity. */
export async function acquireEmbeddedAttemptSessionFileOwner(params: {
sessionFile: string;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<EmbeddedAttemptSessionFileOwner> {
if (params.signal?.aborted) {
throw params.signal.reason;
}
return { sessionFileKey: params.sessionFile, release() {} };
}
export class EmbeddedAttemptSessionTakeoverError extends Error {
constructor(sessionKey: string) {
super(`session changed while the prompt was running: ${sessionKey}`);
this.name = "EmbeddedAttemptSessionTakeoverError";
}
}
export type EmbeddedAttemptSessionLockController = {
assertOwned(): void;
canAdvanceSessionEntryCache(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean;
publishOwnedSessionFileSnapshot(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean;
publishValidatedSessionFileSnapshot(snapshot: OwnedSessionTranscriptCacheSnapshot): boolean;
readTrustedCurrentSessionFileSnapshot(): Promise<undefined>;
isPromptSubmissionBlockedError(error: unknown): boolean;
releaseForPrompt(): Promise<void>;
releaseHeldLockForAbort(options?: { reason?: unknown; terminal?: boolean }): Promise<void>;
refreshAfterOwnedSessionWrite(): void;
reacquireAfterPrompt(): Promise<void>;
withSessionWriteLock<T>(
run: () => Promise<T> | T,
options?: OwnedSessionTranscriptWriteOptions<T>,
): Promise<T>;
acquireForCleanup(params?: { session?: unknown }): Promise<SessionLock>;
hasSessionTakeover(): boolean;
dispose(): Promise<void>;
};
export async function createEmbeddedAttemptSessionLockController(params: {
acquireSessionWriteLock: AcquireSessionWriteLock;
initialAcquireSignal?: AbortSignal;
lockOptions: LockOptions;
runId?: string;
sessionId?: string;
reloadPromptReleasedSessionFile?: () => Promise<void> | void;
}): Promise<EmbeddedAttemptSessionLockController> {
// The runtime caller supplies resolveSessionWriteLockTargetKey(sessionTarget),
// so aliases for one session incarnation converge before lease acquisition.
const noOpLock = await params.acquireSessionWriteLock({
...params.lockOptions,
targetKind: "session-key",
...(params.initialAcquireSignal ? { signal: params.initialAcquireSignal } : {}),
});
let initialLockReleasePromise: Promise<void> | undefined;
let initialLockReleased = false;
const releaseInitialLock = (): Promise<void> => {
if (initialLockReleased) {
return Promise.resolve();
}
initialLockReleasePromise ??= Promise.resolve(noOpLock.release()).then(
() => {
initialLockReleased = true;
},
(error: unknown) => {
initialLockReleasePromise = undefined;
throw error;
},
);
return initialLockReleasePromise;
};
let disposed = false;
let promptAborted = false;
let promptSubmissionBlockedError: Error | undefined;
let takeoverDetected = false;
const assertInitialLockOwned = (): void => {
try {
noOpLock.assertOwned?.();
} catch (error) {
if (isSessionWriteLockAcquireError(error)) {
takeoverDetected = true;
}
throw error;
}
};
let promptReleaseNeedsReload = false;
let cleanupRequested = false;
let promptSettled = Promise.resolve();
let settlePrompt: (() => void) | undefined;
let lifecycle = Promise.resolve();
let reloadFailed = false;
let reloadFailure: unknown;
let disposePromise: Promise<void> | undefined;
let cleanupReleasePromise: Promise<void> | undefined;
let cleanupLockGranted = false;
let cleanupOwnershipReleased: Promise<void> | undefined;
let resolveCleanupOwnershipReleased: (() => void) | undefined;
let rejectCleanupOwnershipReleased: ((error: unknown) => void) | undefined;
type ActiveWriteOperation = {
active: boolean;
settlement?: Promise<void>;
started: boolean;
};
const activeWriteOperations = new Set<ActiveWriteOperation>();
const activeWriteOperation = new AsyncLocalStorage<ActiveWriteOperation>();
type LifecycleOwner = {
active: boolean;
nestedPending: number;
nestedTail: Promise<void>;
pendingOperations: Set<Promise<void>>;
};
const createLifecycleOwner = (): LifecycleOwner => ({
active: true,
nestedPending: 0,
nestedTail: Promise.resolve(),
pendingOperations: new Set(),
});
const lifecycleOwner = new AsyncLocalStorage<LifecycleOwner>();
let teardownBudgetLogged = false;
const createTeardownDeadline = (budgetMs = SESSION_LOCK_TEARDOWN_BUDGET_MS): number =>
performance.now() + budgetMs;
const pendingWriteCount = (): number => activeWriteOperations.size;
const logTeardownBudgetExpiry = (phase: string): void => {
if (teardownBudgetLogged) {
return;
}
teardownBudgetLogged = true;
log.error(
`session lock teardown budget expired; releasing ownership: ` +
`phase=${phase} runId=${params.runId ?? "unknown"} ` +
`sessionId=${params.sessionId ?? "unknown"} ` +
`pendingWrites=${pendingWriteCount()} timeoutMs=${SESSION_LOCK_TEARDOWN_BUDGET_MS}`,
);
};
const settleWithinTeardownDeadline = async (
operation: Promise<void>,
deadline: number,
): Promise<boolean> => {
const waitMs = Math.max(0, deadline - performance.now());
if (waitMs <= 0) {
void operation.catch(() => {});
return false;
}
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation.then(() => true),
new Promise<false>((resolve) => {
timeout = setTimeout(() => resolve(false), waitMs);
timeout.unref?.();
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
};
const drainActiveWrites = async (startedOnly: boolean): Promise<void> => {
while (true) {
const pendingWrites = [...activeWriteOperations]
.filter((operation) => !startedOnly || operation.started)
.flatMap((operation) => (operation.settlement ? [operation.settlement] : []));
if (pendingWrites.length === 0) {
return;
}
await Promise.all(pendingWrites);
}
};
const drainLifecycleOwner = async (owner: LifecycleOwner): Promise<void> => {
let failed = false;
let firstError: unknown;
while (owner.pendingOperations.size > 0) {
const pending = [...owner.pendingOperations];
owner.pendingOperations.clear();
const settled = await Promise.allSettled(pending);
const rejection = settled.find((result) => result.status === "rejected");
if (!failed && rejection?.status === "rejected") {
failed = true;
firstError = rejection.reason;
}
}
if (failed) {
throw firstError;
}
};
const runLifecycleOwner = async <T>(
owner: LifecycleOwner,
run: () => Promise<T> | T,
): Promise<T> => {
let value: T | undefined;
let primaryError: unknown;
let primaryFailed = false;
try {
value = await lifecycleOwner.run(owner, async () => await run());
} catch (error) {
primaryFailed = true;
primaryError = error;
}
let drainError: unknown;
let drainFailed = false;
try {
await drainLifecycleOwner(owner);
} catch (error) {
drainFailed = true;
drainError = error;
} finally {
owner.active = false;
}
if (primaryFailed) {
if (
drainFailed &&
drainError !== primaryError &&
primaryError instanceof Error &&
primaryError.cause === undefined
) {
try {
primaryError.cause = drainError;
} catch {
// Frozen callback errors remain primary; drain failure is secondary.
}
}
throw primaryError;
}
if (drainFailed) {
throw drainError;
}
return value as T;
};
const serializeLifecycle = async <T>(run: () => Promise<T> | T): Promise<T> => {
const inheritedOwner = lifecycleOwner.getStore();
if (inheritedOwner?.active) {
const previousNested = inheritedOwner.nestedTail;
const waitForPrevious = inheritedOwner.nestedPending > 0 ? previousNested : Promise.resolve();
inheritedOwner.nestedPending += 1;
const operation = waitForPrevious.then(async () => {
const childOwner = createLifecycleOwner();
return await runLifecycleOwner(childOwner, run);
});
void operation.catch(() => {});
const queueTail = operation.then(
() => undefined,
() => undefined,
);
const propagated = operation.then(() => undefined);
void propagated.catch(() => {});
inheritedOwner.nestedTail = queueTail;
inheritedOwner.pendingOperations.add(propagated);
void queueTail.finally(() => {
inheritedOwner.nestedPending -= 1;
});
return await operation;
}
const previous = lifecycle;
let release!: () => void;
lifecycle = new Promise<void>((resolve) => {
release = resolve;
});
await previous;
const owner = createLifecycleOwner();
try {
return await runLifecycleOwner(owner, run);
} finally {
release();
}
};
const reloadPromptReleasedState = async (): Promise<void> => {
if (reloadFailed) {
throw reloadFailure;
}
try {
assertInitialLockOwned();
await params.reloadPromptReleasedSessionFile?.();
} catch (error) {
reloadFailed = true;
reloadFailure = error;
if (error instanceof EmbeddedAttemptSessionTakeoverError) {
takeoverDetected = true;
}
throw error;
}
};
const settlePromptRelease = (): void => {
promptReleaseNeedsReload = false;
settlePrompt?.();
settlePrompt = undefined;
};
return {
assertOwned: assertInitialLockOwned,
canAdvanceSessionEntryCache: () => false,
publishOwnedSessionFileSnapshot: () => false,
publishValidatedSessionFileSnapshot: () => false,
readTrustedCurrentSessionFileSnapshot: async () => undefined,
releaseForPrompt: async () => {
if (disposed) {
throw new Error("attempt disposed before prompt submission");
}
await serializeLifecycle(() => {
if (disposed) {
throw new Error("attempt disposed before prompt submission");
}
if (promptSubmissionBlockedError) {
throw promptSubmissionBlockedError;
}
if (cleanupRequested) {
throw new Error("attempt cleanup started before prompt submission");
}
assertInitialLockOwned();
// The SQLite lease spans the provider prompt. Its stale deadline never
// permits takeover from a live process owner.
promptAborted = false;
promptReleaseNeedsReload = true;
promptSettled = new Promise<void>((resolve) => {
settlePrompt = resolve;
});
});
},
isPromptSubmissionBlockedError: (error) =>
promptSubmissionBlockedError !== undefined && error === promptSubmissionBlockedError,
releaseHeldLockForAbort: async (options) => {
promptAborted = true;
if (options?.terminal !== false && !promptSubmissionBlockedError) {
promptSubmissionBlockedError = createPromptSubmissionAbortError(options?.reason);
}
},
refreshAfterOwnedSessionWrite: () => {},
reacquireAfterPrompt: async () => {
if (disposed) {
settlePromptRelease();
return;
}
await serializeLifecycle(async () => {
try {
if (disposed || cleanupRequested || (promptAborted && !promptReleaseNeedsReload)) {
return;
}
await reloadPromptReleasedState();
} finally {
settlePromptRelease();
}
});
},
withSessionWriteLock: (run) => {
const rejectWrite = (error: Error): Promise<never> => {
const rejected = Promise.reject(error);
void rejected.catch(() => {});
return rejected;
};
const parentWriteOperation = activeWriteOperation.getStore();
const activeDescendant = Boolean(
parentWriteOperation?.active && activeWriteOperations.has(parentWriteOperation),
);
if (disposed && !activeDescendant) {
return rejectWrite(new Error("attempt disposed before transcript write"));
}
if (cleanupRequested && !activeDescendant) {
return rejectWrite(new Error("attempt cleanup started before transcript write"));
}
const writeOperation: ActiveWriteOperation = { active: true, started: false };
const operation = serializeLifecycle(async () => {
if (disposed && !activeDescendant) {
throw new Error("attempt disposed before transcript write");
}
if (cleanupRequested && !activeDescendant) {
throw new Error("attempt cleanup started before transcript write");
}
if (reloadFailed) {
throw reloadFailure;
}
assertInitialLockOwned();
writeOperation.started = true;
return await activeWriteOperation.run(writeOperation, async () => await run());
});
const settlement = operation.then(
() => undefined,
() => undefined,
);
void operation.catch(() => {});
writeOperation.settlement = settlement;
activeWriteOperations.add(writeOperation);
void settlement.then(() => {
writeOperation.active = false;
activeWriteOperations.delete(writeOperation);
});
return operation;
},
acquireForCleanup: async () => {
const currentWriteOperation = activeWriteOperation.getStore();
if (currentWriteOperation?.active && activeWriteOperations.has(currentWriteOperation)) {
throw new Error("cannot start attempt cleanup inside a transcript write callback");
}
if (disposed) {
throw new Error("attempt disposed before cleanup");
}
if (cleanupRequested) {
throw new Error("attempt cleanup already started");
}
cleanupRequested = true;
const deadline = createTeardownDeadline();
const cleanupAdmission = (async () => {
await serializeLifecycle(async () => {
if (disposed) {
throw new Error("attempt disposed before cleanup");
}
if (promptReleaseNeedsReload) {
try {
if (!disposed) {
await reloadPromptReleasedState();
}
} finally {
settlePromptRelease();
}
}
});
await serializeLifecycle(() => {});
})();
if (!(await settleWithinTeardownDeadline(cleanupAdmission, deadline))) {
disposed = true;
promptAborted = true;
takeoverDetected = true;
settlePromptRelease();
logTeardownBudgetExpiry("cleanup-acquire");
await releaseInitialLock();
cleanupLockGranted = true;
cleanupOwnershipReleased = new Promise<void>((resolve) => {
resolveCleanupOwnershipReleased = resolve;
});
return {
release: () => {
resolveCleanupOwnershipReleased?.();
return Promise.resolve();
},
} as SessionLock;
}
if (disposed) {
throw new Error("attempt disposed before cleanup");
}
assertInitialLockOwned();
cleanupLockGranted = true;
cleanupOwnershipReleased = new Promise<void>((resolve, reject) => {
resolveCleanupOwnershipReleased = resolve;
rejectCleanupOwnershipReleased = reject;
});
void cleanupOwnershipReleased.catch(() => {});
return {
release: () => {
cleanupReleasePromise ??= (async () => {
try {
const writesSettled = await settleWithinTeardownDeadline(
drainActiveWrites(false),
createTeardownDeadline(),
);
if (!writesSettled) {
logTeardownBudgetExpiry("cleanup-release");
}
await releaseInitialLock();
resolveCleanupOwnershipReleased?.();
} catch (error) {
rejectCleanupOwnershipReleased?.(error);
throw error;
}
})();
return cleanupReleasePromise;
},
} as SessionLock;
},
hasSessionTakeover: () => takeoverDetected,
dispose: async () => {
const currentWriteOperation = activeWriteOperation.getStore();
if (currentWriteOperation?.active && activeWriteOperations.has(currentWriteOperation)) {
throw new Error("cannot dispose an attempt from inside a transcript write callback");
}
disposePromise ??= (async () => {
disposed = true;
promptAborted = true;
if (cleanupLockGranted) {
// Cleanup acquisition already drained the lifecycle queue. Its lock
// release resolves this handoff after any tracked write settlements.
try {
await cleanupOwnershipReleased;
} catch {
await releaseInitialLock();
}
return;
}
try {
await settleWithinTeardownDeadline(
(async () => {
await Promise.all([promptSettled, serializeLifecycle(() => {})]);
await drainActiveWrites(false);
})(),
createTeardownDeadline(PROMPT_DISPOSE_SETTLE_TIMEOUT_MS),
);
if (
!(await settleWithinTeardownDeadline(drainActiveWrites(true), createTeardownDeadline()))
) {
logTeardownBudgetExpiry("dispose");
}
} finally {
await releaseInitialLock();
}
})().catch((error: unknown) => {
disposePromise = undefined;
throw error;
});
await disposePromise;
},
};
}
type PromptReleaseStreamFn = ((...args: unknown[]) => Promise<unknown>) & {
openclawSessionLockPromptReleaseInstalled?: true;
};
type SessionWithAgentPrompt = {
agent?: { streamFn?: PromptReleaseStreamFn };
};
async function settlePromptSubmission(params: {
reacquireAfterPrompt: () => Promise<void>;
}): Promise<void> {
await params.reacquireAfterPrompt();
}
function attachPromptSettlementError(promptError: unknown, settlementError: unknown): void {
if (
promptError instanceof Error &&
promptError.cause === undefined &&
settlementError !== promptError
) {
try {
promptError.cause = settlementError;
} catch {
// A frozen provider error remains the primary failure; settlement diagnostics are secondary.
}
}
}
export function installPromptSubmissionLockRelease(params: {
session: unknown;
releaseForPrompt: () => Promise<void>;
reacquireAfterPrompt: () => Promise<void>;
sessionFile?: string;
sessionKey?: string;
sessionTarget?: SessionTranscriptWriteLockTarget;
withSessionWriteLock?: <T>(
run: () => Promise<T> | T,
options?: OwnedSessionTranscriptWriteOptions<T>,
) => Promise<T>;
assertSessionWriteLockOwned?: () => void;
canAdvanceSessionEntryCache?: (snapshot: OwnedSessionTranscriptCacheSnapshot) => boolean;
publishSessionFileSnapshot?: (snapshot: OwnedSessionTranscriptCacheSnapshot) => boolean;
}): void {
const agent = (params.session as SessionWithAgentPrompt).agent;
if (typeof agent?.streamFn !== "function") {
return;
}
const currentStreamFn = agent.streamFn;
if (currentStreamFn.openclawSessionLockPromptReleaseInstalled === true) {
return;
}
const originalStreamFn = currentStreamFn.bind(agent);
const wrappedStreamFn: PromptReleaseStreamFn = async (...args: unknown[]) => {
// The internal agent runtime routes transcript mutations through the
// lifecycle lock; it has no separate SDK event queue to drain.
await params.releaseForPrompt();
let promptFailed = false;
let promptError: unknown;
let promptResult: unknown;
try {
if (params.sessionFile && params.withSessionWriteLock) {
if (!params.assertSessionWriteLockOwned) {
throw new Error("owned transcript write context requires a session lock ownership fence");
}
promptResult = await withOwnedSessionTranscriptWrites(
{
sessionFile: params.sessionFile,
sessionKey: params.sessionKey,
sessionTarget: params.sessionTarget,
assertOwned: params.assertSessionWriteLockOwned,
withSessionWriteLock: params.withSessionWriteLock,
canAdvanceSessionEntryCache: params.canAdvanceSessionEntryCache,
publishSessionFileSnapshot: params.publishSessionFileSnapshot,
},
async () => await originalStreamFn(...args),
);
} else {
promptResult = await originalStreamFn(...args);
}
} catch (error) {
promptFailed = true;
promptError = error;
}
let settlementFailed = false;
let settlementError: unknown;
try {
await settlePromptSubmission(params);
} catch (error) {
settlementFailed = true;
settlementError = error;
}
if (promptFailed) {
if (settlementFailed) {
attachPromptSettlementError(promptError, settlementError);
}
throw promptError;
}
if (settlementFailed) {
throw settlementError;
}
return promptResult;
};
wrappedStreamFn.openclawSessionLockPromptReleaseInstalled = true;
agent.streamFn = wrappedStreamFn;
}
@@ -3233,8 +3233,7 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
).toBe(false);
});
it("releases the session lock even when teardown cleanup throws", async () => {
const releaseMock = vi.fn(async () => {});
it("disposes the session even when teardown cleanup throws", async () => {
const disposeMock = vi.fn();
const flushMock = vi.fn(async () => {
throw new Error("flush failed");
@@ -3246,12 +3245,10 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
session: { agent: {}, dispose: disposeMock },
sessionManager: hoisted.sessionManager,
bundleLspRuntime: undefined,
sessionLock: { release: releaseMock },
});
expect(flushMock).toHaveBeenCalledTimes(1);
expect(disposeMock).toHaveBeenCalledTimes(1);
expect(releaseMock).toHaveBeenCalledTimes(1);
});
});
@@ -36,8 +36,6 @@ import type { WorkspaceBootstrapFile } from "../../workspace.js";
type SubscribeEmbeddedAgentSessionFn =
typeof import("../../embedded-agent-subscribe.js").subscribeEmbeddedAgentSession;
type AcquireSessionWriteLockFn =
typeof import("../../session-write-lock.js").acquireSessionWriteLock;
type ShouldPreemptivelyCompactBeforePromptFn =
typeof import("./preemptive-compaction.js").shouldPreemptivelyCompactBeforePrompt;
@@ -95,7 +93,6 @@ type AttemptSpawnWorkspaceHoisted = {
buildEmbeddedMessageActionDiscoveryInputMock: UnknownMock;
createOpenClawCodingToolsMock: UnknownMock;
subscribeEmbeddedAgentSessionMock: Mock<SubscribeEmbeddedAgentSessionFn>;
acquireSessionWriteLockMock: Mock<AcquireSessionWriteLockFn>;
installToolResultContextGuardMock: UnknownMock;
installContextEngineLoopHookMock: UnknownMock;
flushPendingToolResultsAfterIdleMock: AsyncUnknownMock;
@@ -186,9 +183,6 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
const subscribeEmbeddedAgentSessionMock = vi.fn<SubscribeEmbeddedAgentSessionFn>(() =>
createSubscriptionMock(),
);
const acquireSessionWriteLockMock = vi.fn<AcquireSessionWriteLockFn>(async (_params) => ({
release: async () => {},
}));
const resolveBootstrapContextForRunMock = vi.fn<() => Promise<BootstrapContext>>(async () => ({
bootstrapFiles: [],
contextFiles: [],
@@ -266,7 +260,6 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
buildEmbeddedMessageActionDiscoveryInputMock,
createOpenClawCodingToolsMock,
subscribeEmbeddedAgentSessionMock,
acquireSessionWriteLockMock,
installToolResultContextGuardMock,
installContextEngineLoopHookMock,
flushPendingToolResultsAfterIdleMock,
@@ -565,20 +558,6 @@ vi.mock("../tool-schema-runtime.js", () => ({
normalizeProviderToolSchemas: ({ tools }: { tools: unknown[] }) => tools,
}));
vi.mock("../../session-write-lock.js", async () => {
const { resolveSessionWriteLockTargetKey } = await vi.importActual<
typeof import("../../session-write-lock.js")
>("../../session-write-lock.js");
return {
acquireSessionWriteLock: (params: Parameters<AcquireSessionWriteLockFn>[0]) =>
hoisted.acquireSessionWriteLockMock(params),
resolveSessionWriteLockAcquireTimeoutMs: () => 60000,
resolveSessionWriteLockOptions: () => ({ timeoutMs: 60000, staleMs: 1_800_000, maxHoldMs: 1 }),
resolveSessionLockMaxHoldFromTimeout: () => 1,
resolveSessionWriteLockTargetKey,
};
});
vi.mock("../tool-result-context-guard.js", async () => {
const actual = await vi.importActual<typeof import("../tool-result-context-guard.js")>(
"../tool-result-context-guard.js",
@@ -1090,9 +1069,6 @@ export function resetEmbeddedAttemptHarness(
hoisted.subscribeEmbeddedAgentSessionMock
.mockReset()
.mockImplementation(() => createSubscriptionMock());
hoisted.acquireSessionWriteLockMock.mockReset().mockResolvedValue({
release: async () => {},
});
hoisted.installToolResultContextGuardMock.mockReset().mockReturnValue(() => {});
hoisted.installContextEngineLoopHookMock.mockReset().mockReturnValue(() => {});
hoisted.flushPendingToolResultsAfterIdleMock.mockReset().mockResolvedValue(undefined);
@@ -11,7 +11,7 @@ describe("cleanupEmbeddedAttemptResources", () => {
vi.restoreAllMocks();
});
it("waits for aborted prompt settlement before flushing and releasing the lock", async () => {
it("waits for aborted prompt settlement before flushing and disposing", async () => {
// After an abort, pending prompt work gets a short chance to settle before
// session flush/release/dispose run.
const order: string[] = [];
@@ -31,11 +31,6 @@ describe("cleanupEmbeddedAttemptResources", () => {
},
},
sessionManager: {},
sessionLock: {
release: async () => {
order.push("release");
},
},
aborted: true,
abortSettlePromise: settle.promise,
runId: "run-1",
@@ -49,10 +44,10 @@ describe("cleanupEmbeddedAttemptResources", () => {
settle.resolve();
await cleanupPromise;
expect(order).toEqual(["guard", "flush", "release", "dispose"]);
expect(order).toEqual(["guard", "flush", "dispose"]);
});
it("releases the lock after the aborted settle timeout", async () => {
it("continues cleanup after the aborted settle timeout", async () => {
vi.useFakeTimers();
vi.spyOn(log, "warn").mockImplementation(() => {});
const order: string[] = [];
@@ -68,11 +63,6 @@ describe("cleanupEmbeddedAttemptResources", () => {
},
},
sessionManager: {},
sessionLock: {
release: async () => {
order.push("release");
},
},
aborted: true,
abortSettlePromise: new Promise(() => {}),
runId: "run-1",
@@ -86,12 +76,10 @@ describe("cleanupEmbeddedAttemptResources", () => {
await vi.advanceTimersByTimeAsync(1);
await cleanupPromise;
expect(order).toEqual(["flush", "release", "dispose"]);
expect(order).toEqual(["flush", "dispose"]);
});
it("releases the lock before runtime teardown can hang", async () => {
// Bundle runtime disposal can hang; release transcript locks first so other
// turns are not blocked by diagnostic cleanup.
it("disposes the session before runtime teardown can hang", async () => {
const order: string[] = [];
let markRuntimeDisposeStarted!: () => void;
const runtimeDisposeStarted = new Promise<void>((resolve) => {
@@ -109,11 +97,6 @@ describe("cleanupEmbeddedAttemptResources", () => {
},
},
sessionManager: {},
sessionLock: {
release: async () => {
order.push("release");
},
},
bundleMcpRuntime: {
dispose: async () => {
order.push("runtime-dispose-start");
@@ -125,81 +108,25 @@ describe("cleanupEmbeddedAttemptResources", () => {
await runtimeDisposeStarted;
expect(order).toEqual(["flush", "release", "dispose", "runtime-dispose-start"]);
expect(order).toEqual(["flush", "dispose", "runtime-dispose-start"]);
});
it("does not wait for the settle promise on non-aborted cleanup", async () => {
const release = vi.fn(async () => {});
const dispose = vi.fn();
await cleanupEmbeddedAttemptResources({
flushPendingToolResultsAfterIdle: vi.fn(async () => {}),
session: {
agent: {},
dispose: vi.fn(),
dispose,
},
sessionManager: {},
sessionLock: { release },
aborted: false,
abortSettlePromise: new Promise(() => {}),
runId: "run-1",
sessionId: "session-1",
});
expect(release).toHaveBeenCalledTimes(1);
});
it("still disposes resources when lock release fails", async () => {
const releaseError = new Error("release failed");
const dispose = vi.fn();
const runtimeDispose = vi.fn(async () => {});
await expect(
cleanupEmbeddedAttemptResources({
flushPendingToolResultsAfterIdle: vi.fn(async () => {}),
session: {
agent: {},
dispose,
},
sessionManager: {},
sessionLock: {
release: async () => {
throw releaseError;
},
},
bundleMcpRuntime: {
dispose: runtimeDispose,
},
}),
).rejects.toBe(releaseError);
expect(dispose).toHaveBeenCalledTimes(1);
expect(runtimeDispose).toHaveBeenCalledTimes(1);
});
it("can skip stale session-manager flushing after session takeover", async () => {
const flushPendingToolResultsAfterIdle = vi.fn(async () => {});
const order: string[] = [];
const dispose = vi.fn(() => {
order.push("dispose");
});
const release = vi.fn(async () => {
order.push("release");
});
await cleanupEmbeddedAttemptResources({
flushPendingToolResultsAfterIdle,
session: {
agent: {},
dispose,
},
sessionManager: {},
sessionLock: { release },
skipSessionFlush: true,
});
expect(flushPendingToolResultsAfterIdle).not.toHaveBeenCalled();
expect(dispose).toHaveBeenCalledTimes(1);
expect(release).toHaveBeenCalledTimes(1);
expect(order).toEqual(["release", "dispose"]);
});
});
@@ -1,7 +1,6 @@
/**
* Builds subscription params and cleans up embedded attempt resources.
*/
import { toErrorObject } from "../../../infra/errors.js";
import type { SubscribeEmbeddedAgentSessionParams } from "../../embedded-agent-subscribe.types.js";
import { log } from "../logger.js";
import { resolveEmbeddedAbortSettleTimeoutMs } from "./attempt.abort-settle-timeout.js";
@@ -29,7 +28,7 @@ async function waitForEmbeddedAbortSettle(params: {
let timeout: NodeJS.Timeout | undefined;
// Abort settlement is advisory cleanup; timeout or errors are logged but do
// not block releasing the session write-lock.
// not block disposing attempt-owned resources.
const outcome = await Promise.race([
params.promise
.then(() => "settled" as const)
@@ -65,10 +64,8 @@ export function buildEmbeddedSubscriptionParams(
}
/**
* Tears down per-attempt resources in lock-safe order: remove guards, settle
* aborted prompts, flush tool results, release the session lock, then dispose
* runtimes. Lock release errors are reported after best-effort disposal so a
* failed lock does not leak spawned runtimes.
* Tears down per-attempt resources after the transcript lifecycle has drained:
* remove guards, settle aborted prompts, flush tool results, then dispose runtimes.
*/
export async function cleanupEmbeddedAttemptResources(params: {
removeToolResultContextGuard?: () => void;
@@ -81,49 +78,31 @@ export async function cleanupEmbeddedAttemptResources(params: {
sessionManager: unknown;
bundleMcpRuntime?: { dispose(): Promise<void> | void };
bundleLspRuntime?: { dispose(): Promise<void> | void };
sessionLock: { release(): Promise<void> | void };
aborted?: boolean;
abortSettlePromise?: Promise<unknown> | null;
skipSessionFlush?: boolean;
runId?: string;
sessionId?: string;
}): Promise<void> {
let sessionLockReleaseError: unknown;
try {
try {
params.removeToolResultContextGuard?.();
} catch {
/* best-effort */
}
if (params.aborted && params.abortSettlePromise) {
await waitForEmbeddedAbortSettle({
promise: params.abortSettlePromise,
runId: params.runId ?? "unknown",
sessionId: params.sessionId ?? "unknown",
});
}
// PERF: When the run was aborted (user stop / timeout), skip the expensive
// waitForIdle (up to 30 s) and flush pending tool results synchronously so
// the session write-lock is released without leaving orphaned tool calls.
if (!params.skipSessionFlush) {
try {
await params.flushPendingToolResultsAfterIdle({
agent: params.session?.agent as IdleAwareAgent | null | undefined,
sessionManager: params.sessionManager as ToolResultFlushManager | null | undefined,
...(params.aborted ? { timeoutMs: 0 } : {}),
});
} catch {
/* best-effort */
}
}
} finally {
try {
// Release the write-lock before disposing runtimes so another attempt can
// recover even if runtime disposal stalls or throws.
await params.sessionLock.release();
} catch (err) {
sessionLockReleaseError = err;
}
params.removeToolResultContextGuard?.();
} catch {
/* best-effort */
}
if (params.aborted && params.abortSettlePromise) {
await waitForEmbeddedAbortSettle({
promise: params.abortSettlePromise,
runId: params.runId ?? "unknown",
sessionId: params.sessionId ?? "unknown",
});
}
try {
await params.flushPendingToolResultsAfterIdle({
agent: params.session?.agent as IdleAwareAgent | null | undefined,
sessionManager: params.sessionManager as ToolResultFlushManager | null | undefined,
...(params.aborted ? { timeoutMs: 0 } : {}),
});
} catch {
/* best-effort */
}
try {
@@ -141,8 +120,4 @@ export async function cleanupEmbeddedAttemptResources(params: {
} catch {
/* best-effort */
}
if (sessionLockReleaseError) {
throw toErrorObject(sessionLockReleaseError, "Non-Error thrown");
}
}
@@ -53,51 +53,6 @@ describe("runEmbeddedAttempt tool-search catalog cleanup", () => {
tempPaths.length = 0;
});
it("clears the registered run catalog when the run aborts during prep", async () => {
const abortController = new AbortController();
const prompt = vi.fn(async () => {});
const abortError = new Error("stopped during lock acquisition");
abortError.name = "AbortError";
let markLockRequested!: () => void;
const lockRequested = new Promise<void>((resolve) => {
markLockRequested = resolve;
});
hoisted.acquireSessionWriteLockMock.mockImplementationOnce(async (params) => {
markLockRequested();
await new Promise<void>((resolve) => {
params.signal?.addEventListener("abort", () => resolve(), { once: true });
});
throw params.signal?.reason;
});
hoisted.createOpenClawCodingToolsMock.mockImplementation(() => catalogProbeTools());
const runId = "run-catalog-abort";
const attempt = createContextEngineAttemptRunner({
contextEngine: createContextEngineBootstrapAndAssemble(),
sessionKey: "agent:main:telegram:direct:123",
tempPaths,
sessionPrompt: prompt,
attemptOverrides: {
runId,
abortSignal: abortController.signal,
disableTools: false,
config: {
tools: { toolSearch: { enabled: true, mode: "tools" } },
},
},
});
await lockRequested;
const catalogRef = requireAttemptCatalogRef();
expect(catalogRef.current?.entries).toContainEqual(
expect.objectContaining({ name: "cataloged_probe_tool" }),
);
abortController.abort(abortError);
await expect(attempt).rejects.toBe(abortError);
expect(catalogRef.current).toBeUndefined();
});
it.each([
{
mode: "code-mode",
@@ -31,7 +31,6 @@ import { prepareEmbeddedAttemptBundleTools } from "./attempt-bundle-tools.js";
import { runEmbeddedAttemptExecutionPhase } from "./attempt-execution-phase.js";
import type { EmbeddedAttemptExecutionState } from "./attempt-execution-types.js";
import { cleanupEmbeddedAttemptSessionPhase } from "./attempt-session-cleanup.js";
import { prepareEmbeddedAttemptSessionLock } from "./attempt-session-lock-prepare.js";
import { prepareEmbeddedAttemptSessionRuntime } from "./attempt-session-runtime-prepare.js";
import { prepareEmbeddedAttemptSetup } from "./attempt-setup.js";
import { createEmbeddedRunStageTracker } from "./attempt-stage-timing.js";
@@ -43,7 +42,7 @@ import {
import { prepareEmbeddedAttemptSystemPrompt } from "./attempt-system-prompt-prepare.js";
import { prepareEmbeddedAttemptToolBase } from "./attempt-tool-base-prepare.js";
import { prepareEmbeddedAttemptToolCatalog } from "./attempt-tool-catalog.js";
import type { EmbeddedAttemptSessionFileOwner } from "./attempt.session-lock.js";
import { prepareEmbeddedAttemptTranscriptLifecycle } from "./attempt-transcript-lifecycle-prepare.js";
import {
queueSessionsYieldInterruptMessage,
SESSIONS_YIELD_ABORT_REASON,
@@ -97,9 +96,6 @@ export async function runEmbeddedAttempt(
executionState.terminal = mergeAgentRunAttemptTerminal(executionState.terminal, incoming);
};
let emitDiagnosticRunCompleted: EmitDiagnosticRunCompleted | undefined;
// Releases the eager session lock if post-prompt code exits before cleanup.
let releaseRetainedSessionLock: (() => Promise<void>) | undefined;
let retainedSessionFileOwner: EmbeddedAttemptSessionFileOwner | undefined;
let bundleMcpRuntime: Awaited<ReturnType<typeof materializeBundleMcpToolsForRun>> | undefined;
let bundleLspRuntime: Awaited<ReturnType<typeof createBundleLspToolRuntime>> | undefined;
let toolSearchCatalogRef: ToolSearchCatalogRef | undefined;
@@ -348,21 +344,14 @@ export async function runEmbeddedAttempt(
const {
compactionTimeoutMs,
ownedTranscriptWriteContext,
sessionLockController,
withOwnedSessionWriteLock,
transcriptLifecycle,
withOwnedTranscriptWrite,
} = await measureEmbeddedAgentPreparation(
"attempt.session-lock",
"attempt.transcript-lifecycle",
() =>
prepareEmbeddedAttemptSessionLock({
prepareEmbeddedAttemptTranscriptLifecycle({
attempt: params,
externalAbortController,
getSessionManager: () => sessionManager,
onSessionFileOwnerAcquired: (owner) => {
retainedSessionFileOwner = owner;
},
onSessionLockReleaseReady: (release) => {
releaseRetainedSessionLock = release;
},
}),
{ config: params.config },
);
@@ -390,8 +379,8 @@ export async function runEmbeddedAttempt(
replayAllowedToolNames: toolSearchRunPlan.replayAllowedToolNames,
resolveActiveContextEnginePluginId,
sessionAgentId,
sessionLockController,
withOwnedSessionWriteLock,
transcriptLifecycle,
withOwnedTranscriptWrite,
},
agentSession: {
agentCoreThinkingLevel,
@@ -478,8 +467,7 @@ export async function runEmbeddedAttempt(
sessionLock: {
compactionTimeoutMs,
ownedTranscriptWriteContext,
sessionLockController,
withOwnedSessionWriteLock,
withOwnedTranscriptWrite,
},
setup: {
effectiveFsWorkspaceOnly,
@@ -521,7 +509,7 @@ export async function runEmbeddedAttempt(
attempt: params,
session,
sessionManager,
sessionLockController,
transcriptLifecycle,
bundleMcpRuntime,
bundleLspRuntime,
removeToolResultContextGuard,
@@ -559,14 +547,6 @@ export async function runEmbeddedAttempt(
`failed to clean up embedded prep resources after early attempt exit: runId=${params.runId} ${String(cleanupErr)}`,
);
}
try {
await releaseRetainedSessionLock?.();
} catch (releaseErr) {
log.error(
`failed to release retained session lock on attempt teardown: runId=${params.runId} ${String(releaseErr)}`,
);
}
retainedSessionFileOwner?.release();
const terminal = projectAgentRunAttemptTerminal(executionState.terminal);
emitDiagnosticRunCompleted?.(
terminal.aborted ? "aborted" : "error",
@@ -5,6 +5,7 @@ import {
} from "../../../config/sessions/session-accessor.js";
import * as sessionAccessor from "../../../config/sessions/session-accessor.js";
import { useTempSessionsFixture } from "../../../config/sessions/test-helpers.js";
import { SessionTranscriptWriterClaimReboundError } from "../../../config/sessions/transcript-write-context.js";
import { appendExactAssistantMessageToSessionTranscript } from "../../../config/sessions/transcript.js";
import type { InternalSessionEntry } from "../../../config/sessions/types.js";
import {
@@ -166,7 +167,7 @@ describe("embedded run durable writer admission", () => {
usage: buildUsageWithNoCost({}),
}),
),
).toThrow("Session transcript header was not persisted");
).toThrow(SessionTranscriptWriterClaimReboundError);
const staleAppend = await appendExactAssistantMessageToSessionTranscript({
agentId: "main",
+2 -2
View File
@@ -769,8 +769,8 @@ export function getActiveEmbeddedRunSnapshot(
/**
* Wait for active embedded runs to drain.
*
* Used during restarts so in-flight runs can release session write locks before
* the next lifecycle starts. If no timeout is passed, waits indefinitely.
* Used during restarts so in-flight runs can finish transcript writes before the
* next lifecycle starts. If no timeout is passed, waits indefinitely.
*/
export async function waitForActiveEmbeddedRuns(
timeoutMs?: number,
+11 -167
View File
@@ -22,7 +22,6 @@ import {
resolveModelFallbackError,
} from "./failover-error.js";
import { AgentHarnessSessionSupersededError } from "./harness/errors.js";
import { SessionWriteLockTimeoutError } from "./session-write-lock-error.js";
// OpenAI 429 example shape: https://help.openai.com/en/articles/5955604-how-can-i-solve-429-too-many-requests-errors
const OPENAI_RATE_LIMIT_MESSAGE =
@@ -528,87 +527,6 @@ describe("failover-error", () => {
).toBe("overloaded");
});
it("does not classify session lock wait errors as model timeout failover", () => {
const sessionLockError = new SessionWriteLockTimeoutError({
timeoutMs: 10_000,
owner: "pid=37121",
lockPath: "/tmp/openclaw/session.jsonl.lock",
});
expect(resolveFailoverReasonFromError(sessionLockError)).toBeNull();
expect(isTimeoutError(sessionLockError)).toBe(false);
const wrappedLockError = Object.assign(new Error("operation timed out"), {
name: "AbortError",
cause: sessionLockError,
});
expect(resolveFailoverReasonFromError(wrappedLockError)).toBeNull();
expect(isTimeoutError(wrappedLockError)).toBe(false);
const abortWrappedLockError = Object.assign(new Error("request was aborted"), {
name: "AbortError",
cause: sessionLockError,
});
expect(resolveFailoverReasonFromError(abortWrappedLockError)).toBeNull();
expect(isTimeoutError(abortWrappedLockError)).toBe(false);
});
it("keeps explicit provider failover metadata authoritative over nested session lock text", () => {
expect(
resolveFailoverReasonFromError({
status: 429,
code: "RESOURCE_EXHAUSTED",
message: "upstream quota pressure",
cause: new SessionWriteLockTimeoutError({
timeoutMs: 10_000,
owner: "pid=37121",
lockPath: "/tmp/openclaw/session.jsonl.lock",
}),
}),
).toBe("rate_limit");
});
it("keeps inferred HTTP failover metadata authoritative over nested session lock text", () => {
expect(
resolveFailoverReasonFromError({
message: "HTTP 429: upstream quota pressure",
cause: new SessionWriteLockTimeoutError({
timeoutMs: 10_000,
owner: "pid=37121",
lockPath: "/tmp/openclaw/session.jsonl.lock",
}),
}),
).toBe("rate_limit");
});
it("does not treat generic abort codes as explicit failover metadata over nested session lock text", () => {
expect(
resolveFailoverReasonFromError({
name: "AbortError",
code: "ABORT_ERR",
message: "The operation was aborted",
cause: new SessionWriteLockTimeoutError({
timeoutMs: 10_000,
owner: "pid=37121",
lockPath: "/tmp/openclaw/session.jsonl.lock",
}),
}),
).toBeNull();
});
it("does not let cause-based failover classification bypass wrapper session lock suppression", () => {
expect(
resolveFailoverReasonFromError({
message: "wrapper",
reason: new SessionWriteLockTimeoutError({
timeoutMs: 10_000,
owner: "pid=37121",
lockPath: "/tmp/openclaw/session.jsonl.lock",
}),
cause: new Error("operation timed out"),
}),
).toBeNull();
});
it("classifies bare shared model runtime stream wrapper as timeout regardless of provider (#71620)", () => {
expect(
resolveFailoverReasonFromError({
@@ -1411,26 +1329,14 @@ describe("failover-error", () => {
});
describe("isNonProviderRuntimeCoordinationError", () => {
const makeSessionLockError = () =>
new SessionWriteLockTimeoutError({
timeoutMs: 10_000,
owner: "pid=37121",
lockPath: "/tmp/openclaw/session.jsonl.lock",
});
const makeEmbeddedTakeoverError = () => {
const err = new Error(
"session file changed while embedded prompt lock was released: /tmp/openclaw/session.jsonl",
);
err.name = "EmbeddedAttemptSessionTakeoverError";
const makeWriterClaimReboundError = () => {
const err = new Error("session writer claim changed before transcript persistence");
err.name = "SessionTranscriptWriterClaimReboundError";
return err;
};
it("returns true for direct session write-lock timeout errors", () => {
expect(isNonProviderRuntimeCoordinationError(makeSessionLockError())).toBe(true);
});
it("returns true for direct embedded attempt session takeover errors", () => {
expect(isNonProviderRuntimeCoordinationError(makeEmbeddedTakeoverError())).toBe(true);
it("returns true for transcript writer claim rebound errors", () => {
expect(isNonProviderRuntimeCoordinationError(makeWriterClaimReboundError())).toBe(true);
});
it("returns true for harness session generation ownership loss", () => {
@@ -1467,11 +1373,8 @@ describe("failover-error", () => {
});
it("returns true when the coordination error is nested via cause", () => {
const wrapped = new Error("wrapper", { cause: makeSessionLockError() });
expect(isNonProviderRuntimeCoordinationError(wrapped)).toBe(true);
const wrappedTakeover = new Error("wrapper", { cause: makeEmbeddedTakeoverError() });
expect(isNonProviderRuntimeCoordinationError(wrappedTakeover)).toBe(true);
const wrappedRebound = new Error("wrapper", { cause: makeWriterClaimReboundError() });
expect(isNonProviderRuntimeCoordinationError(wrappedRebound)).toBe(true);
});
it("returns true for Codex missing tool-result local execution failures", () => {
@@ -1494,14 +1397,6 @@ describe("failover-error", () => {
expect(isNonProviderRuntimeCoordinationError({ status: 429, message: "rate limit" })).toBe(
false,
);
expect(
isNonProviderRuntimeCoordinationError({
status: 429,
code: "RESOURCE_EXHAUSTED",
message: "upstream quota pressure",
cause: makeSessionLockError(),
}),
).toBe(false);
expect(
isNonProviderRuntimeCoordinationError({
status: 503,
@@ -1529,63 +1424,12 @@ describe("failover-error", () => {
).toBe(false);
});
it("returns false when takeover wrapper holds a classifiable provider timeout in promptError", () => {
// Simulates EmbeddedAttemptPromptErrorWithCleanupTakeoverError: name matches
// EmbeddedAttemptSessionTakeoverError, but the real cause is in .promptError.
const timeoutPromptErr = Object.assign(new Error("request timed out"), {
name: "TimeoutError",
});
const wrapper = Object.assign(new Error("request timed out"), {
name: "EmbeddedAttemptSessionTakeoverError",
promptError: timeoutPromptErr,
});
expect(isNonProviderRuntimeCoordinationError(wrapper)).toBe(false);
});
it("returns false when takeover wrapper holds rate_limit in promptError", () => {
const rateLimitPromptErr = {
status: 429,
code: "RATE_LIMITED",
message: "too many requests",
};
const wrapper = Object.assign(new Error("cleanup takeover"), {
name: "EmbeddedAttemptSessionTakeoverError",
promptError: rateLimitPromptErr,
});
expect(isNonProviderRuntimeCoordinationError(wrapper)).toBe(false);
const resolution = resolveModelFallbackError(wrapper);
expect(resolution).toMatchObject({
kind: "failover",
error: {
message: "too many requests",
reason: "rate_limit",
status: 429,
code: "RATE_LIMITED",
},
});
expect(resolution.error).toHaveProperty("cause", wrapper);
});
it("returns true when takeover wrapper holds an unclassifiable promptError", () => {
const unknownPromptErr = { weirdField: "something unknown" };
const wrapper = Object.assign(new Error("unknown"), {
name: "EmbeddedAttemptSessionTakeoverError",
promptError: unknownPromptErr,
});
expect(isNonProviderRuntimeCoordinationError(wrapper)).toBe(true);
});
it("returns true for pure takeover error without promptError (regression)", () => {
const pureTakeover = Object.assign(
new Error("session file changed while embedded prompt lock was released"),
{ name: "EmbeddedAttemptSessionTakeoverError" },
);
expect(isNonProviderRuntimeCoordinationError(pureTakeover)).toBe(true);
it("keeps provider-looking rebound wrappers in the coordination path", () => {
const rebound = makeWriterClaimReboundError();
expect(isNonProviderRuntimeCoordinationError(rebound)).toBe(true);
expect(
isNonProviderRuntimeCoordinationError(
Object.assign(new Error("provider rejected request: rate limit"), {
name: "EmbeddedAttemptSessionTakeoverError",
}),
new Error("provider rejected request: rate limit", { cause: rebound }),
),
).toBe(true);
});
+15 -81
View File
@@ -10,7 +10,6 @@ import { collectErrorGraphCandidates, readErrorName } from "../infra/errors.js";
import {
classifyFailoverSignal,
extractFailoverSignalDetails,
inferSignalStatus,
isUnclassifiedNoBodyHttpSignal,
type FailoverClassification,
type FailoverSignal,
@@ -18,7 +17,6 @@ import {
import { isTimeoutErrorMessage } from "./embedded-agent-helpers/errors.js";
import type { FailoverReason } from "./embedded-agent-helpers/types.js";
import { AgentHarnessSessionSupersededError } from "./harness/errors.js";
import { isSessionWriteLockAcquireError } from "./session-write-lock-error.js";
const ABORT_TIMEOUT_RE = /request was aborted|request aborted/i;
const MAX_FAILOVER_CAUSE_DEPTH = 25;
@@ -390,49 +388,15 @@ function normalizeDirectErrorSignal(err: unknown): FailoverSignal {
};
}
function hasSessionWriteLockContention(err: unknown, seen: Set<object> = new Set()): boolean {
if (isSessionWriteLockAcquireError(err)) {
return true;
}
if (!err || typeof err !== "object") {
return false;
}
if (seen.has(err)) {
return false;
}
seen.add(err);
const candidate = err as { error?: unknown; cause?: unknown; reason?: unknown };
return (
hasSessionWriteLockContention(candidate.error, seen) ||
hasSessionWriteLockContention(candidate.cause, seen) ||
hasSessionWriteLockContention(candidate.reason, seen)
);
}
function isEmbeddedAttemptSessionTakeover(err: unknown): boolean {
// Match by name to avoid importing embedded-agent-runner here (would create a cycle).
return Boolean(
err && typeof err === "object" && readErrorName(err) === "EmbeddedAttemptSessionTakeoverError",
);
}
function hasPreservedTakeoverPromptError(err: unknown): err is Record<"promptError", unknown> {
return Boolean(
isEmbeddedAttemptSessionTakeover(err) &&
function hasSessionTranscriptWriterClaimRebound(
err: unknown,
seen: Set<object> = new Set(),
): boolean {
if (
err &&
typeof err === "object" &&
Object.hasOwn(err, "promptError"),
);
}
function resolveFailoverSourceError(err: unknown): unknown {
// Cleanup takeover is a secondary failure when the wrapper preserves the
// prompt error. Classify and report that provider-facing source instead.
return hasPreservedTakeoverPromptError(err) ? err.promptError : err;
}
function hasEmbeddedAttemptSessionTakeover(err: unknown, seen: Set<object> = new Set()): boolean {
if (isEmbeddedAttemptSessionTakeover(err)) {
readErrorName(err) === "SessionTranscriptWriterClaimReboundError"
) {
return true;
}
if (!err || typeof err !== "object") {
@@ -444,9 +408,9 @@ function hasEmbeddedAttemptSessionTakeover(err: unknown, seen: Set<object> = new
seen.add(err);
const candidate = err as { error?: unknown; cause?: unknown; reason?: unknown };
return (
hasEmbeddedAttemptSessionTakeover(candidate.error, seen) ||
hasEmbeddedAttemptSessionTakeover(candidate.cause, seen) ||
hasEmbeddedAttemptSessionTakeover(candidate.reason, seen)
hasSessionTranscriptWriterClaimRebound(candidate.error, seen) ||
hasSessionTranscriptWriterClaimRebound(candidate.cause, seen) ||
hasSessionTranscriptWriterClaimRebound(candidate.reason, seen)
);
}
@@ -534,9 +498,6 @@ function hasTimeoutHint(err: unknown): boolean {
if (!err) {
return false;
}
if (hasSessionWriteLockContention(err)) {
return false;
}
if (readErrorName(err) === "TimeoutError") {
return true;
}
@@ -555,9 +516,6 @@ export function isTimeoutError(err: unknown): boolean {
if (readErrorName(err) !== "AbortError") {
return false;
}
if (hasSessionWriteLockContention(err)) {
return false;
}
const message = getErrorMessage(err);
if (message && ABORT_TIMEOUT_RE.test(message)) {
return true;
@@ -667,14 +625,6 @@ function resolveFailoverClassificationFromErrorInternal(
};
}
const signal = normalizeErrorSignal(err, providerHint);
const codeReason = signal.code
? failoverReasonFromClassification(classifyFailoverSignal({ code: signal.code }))
: null;
const hasExplicitFailoverMetadata =
typeof inferSignalStatus(signal) === "number" ||
(codeReason !== null && codeReason !== "timeout");
const hasSessionLock = hasSessionWriteLockContention(err);
const classification = classifyFailoverSignal(signal);
const nestedCandidates = getNestedErrorCandidates(err);
@@ -687,9 +637,6 @@ function resolveFailoverClassificationFromErrorInternal(
providerHint,
);
if (nestedClassification) {
if (hasSessionLock && !hasExplicitFailoverMetadata) {
return null;
}
return nestedClassification;
}
}
@@ -713,16 +660,9 @@ function resolveFailoverClassificationFromErrorInternal(
}
if (classification) {
if (hasSessionLock && !hasExplicitFailoverMetadata) {
return null;
}
return classification;
}
if (hasSessionLock) {
return null;
}
if (isTimeoutError(err)) {
return {
kind: "reason",
@@ -865,7 +805,7 @@ export function coerceToFailoverError(
err: unknown,
context?: FailoverErrorContext,
): FailoverError | null {
const sourceError = resolveFailoverSourceError(err);
const sourceError = err;
if (isFailoverError(sourceError)) {
if (context?.authMode && !sourceError.authMode) {
const message =
@@ -938,22 +878,16 @@ export function resolveModelFallbackError(
) {
return { kind: "coordination", error: err };
}
// A direct takeover remains a coordination failure unless the dedicated
// cleanup wrapper owns a preserved prompt error. Its message alone must not
// reclassify session-state loss as a provider failure.
if (isEmbeddedAttemptSessionTakeover(err) && !hasPreservedTakeoverPromptError(err)) {
// The in-transaction transcript fence owns writer supersession. A rebound is
// local coordination failure even when provider-looking wrappers contain it.
if (hasSessionTranscriptWriterClaimRebound(err)) {
return { kind: "coordination", error: err };
}
const failoverError = coerceToFailoverError(err, context);
if (failoverError) {
return { kind: "failover", error: failoverError };
}
if (
hasSessionWriteLockContention(err) ||
hasEmbeddedAttemptSessionTakeover(err) ||
hasMissingToolResultFailure(err) ||
staleLifecycleFailure
) {
if (hasMissingToolResultFailure(err) || staleLifecycleFailure) {
return { kind: "coordination", error: err };
}
return { kind: "unknown", error: err };
@@ -1,3 +1,4 @@
import type { OpenClawConfig } from "../../config/config.js";
import { runWithSessionTranscriptReadFence } from "../../config/sessions/session-transcript-read-fence.js";
/**
* Manages context-engine lifecycle hooks for native agent harnesses.
@@ -25,7 +26,6 @@ import {
} from "../embedded-agent-runner/run/attempt.prompt-helpers.js";
import { stripRuntimeContextCustomMessages } from "../internal-runtime-context.js";
import type { AgentMessage } from "../runtime/index.js";
import type { SessionWriteLockAcquireTimeoutConfig } from "../session-write-lock.js";
type HarnessContextEngine = ContextEngine;
@@ -116,7 +116,7 @@ export async function bootstrapHarnessContextEngine(params: {
fallbackReason?: string | null;
degradedReason?: string | null;
runMaintenance?: typeof runHarnessContextEngineMaintenance;
config?: SessionWriteLockAcquireTimeoutConfig;
config?: OpenClawConfig;
warn: (message: string) => void;
}): Promise<void> {
if (
@@ -286,7 +286,7 @@ export async function finalizeHarnessContextEngineTurn(params: {
degradedReason?: string | null;
runMaintenance?: typeof runHarnessContextEngineMaintenance;
sessionManager?: unknown;
config?: SessionWriteLockAcquireTimeoutConfig;
config?: OpenClawConfig;
warn: (message: string) => void;
/** True when this turn belongs to a heartbeat run. */
isHeartbeat?: boolean;
@@ -442,7 +442,7 @@ export async function runHarnessContextEngineMaintenance(params: {
executionMode?: "foreground" | "background";
onDeferredMaintenance?: (promise: Promise<void>) => void;
withSessionManagerRewriteLock?: <T>(operation: () => Promise<T> | T) => Promise<T>;
config?: SessionWriteLockAcquireTimeoutConfig;
config?: OpenClawConfig;
}) {
const runtimeSettings = buildHarnessContextEngineRuntimeSettings(params);
return await runContextEngineMaintenance({
+10 -172
View File
@@ -45,7 +45,6 @@ import {
} from "./run-termination.js";
import { toSandboxProvisioningError } from "./sandbox/provisioning-error.js";
import { resolveSessionSuspensionReason } from "./session-suspension.js";
import { SessionWriteLockTimeoutError } from "./session-write-lock-error.js";
import { makeModelFallbackCfg } from "./test-helpers/model-fallback-config-fixture.js";
const emptyManifestPlugins = [] as const;
@@ -1968,7 +1967,7 @@ describe("runWithModelFallback", () => {
expect(run).toHaveBeenCalledTimes(1);
});
it("aborts the fallback chain on embedded session takeover instead of trying every model (#83510)", async () => {
it("aborts the fallback chain when the transcript writer claim rebounds", async () => {
const cfg = makeCfg({
agents: {
defaults: {
@@ -1979,11 +1978,9 @@ describe("runWithModelFallback", () => {
},
},
});
const takeoverError = new Error(
"session file changed while embedded prompt lock was released: /tmp/session.jsonl",
);
takeoverError.name = "EmbeddedAttemptSessionTakeoverError";
const run = vi.fn().mockRejectedValue(takeoverError);
const reboundError = new Error("session writer claim changed before transcript persistence");
reboundError.name = "SessionTranscriptWriterClaimReboundError";
const run = vi.fn().mockRejectedValue(reboundError);
await expect(
runWithModelFallback({
@@ -1992,7 +1989,7 @@ describe("runWithModelFallback", () => {
model: "gpt-5.4",
run,
}),
).rejects.toBe(takeoverError);
).rejects.toBe(reboundError);
expect(run).toHaveBeenCalledTimes(1);
});
@@ -2184,7 +2181,7 @@ describe("runWithModelFallback", () => {
expect(onFallbackStep).not.toHaveBeenCalled();
});
it("aborts fallback when a provider prompt error carries cleanup session takeover", async () => {
it("aborts fallback when a provider-looking wrapper carries writer claim rebound", async () => {
const cfg = makeCfg({
agents: {
defaults: {
@@ -2195,14 +2192,13 @@ describe("runWithModelFallback", () => {
},
},
});
const cleanupTakeover = new Error(
"session file changed while embedded prompt lock was released: /tmp/session.jsonl",
const writerClaimRebound = new Error(
"session writer claim changed before transcript persistence",
);
cleanupTakeover.name = "EmbeddedAttemptSessionTakeoverError";
writerClaimRebound.name = "SessionTranscriptWriterClaimReboundError";
const providerFacingError = new Error("provider rejected request: rate limit", {
cause: cleanupTakeover,
cause: writerClaimRebound,
});
providerFacingError.name = "EmbeddedAttemptSessionTakeoverError";
const run = vi.fn().mockRejectedValue(providerFacingError);
await expect(
@@ -2216,35 +2212,6 @@ describe("runWithModelFallback", () => {
expect(run).toHaveBeenCalledTimes(1);
});
it("aborts the fallback chain on session write-lock timeout instead of trying every model (#83510)", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-5.4",
fallbacks: ["anthropic/claude-sonnet-4-6", "openai/gpt-4.1-mini"],
},
},
},
});
const lockError = new SessionWriteLockTimeoutError({
timeoutMs: 10_000,
owner: "pid=37121",
lockPath: "/tmp/openclaw/session.jsonl.lock",
});
const run = vi.fn().mockRejectedValue(lockError);
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-5.4",
run,
}),
).rejects.toBe(lockError);
expect(run).toHaveBeenCalledTimes(1);
});
it("aborts the fallback chain on stale gateway lifecycle errors (#116418)", async () => {
const cfg = makeCfg({
agents: {
@@ -2423,49 +2390,6 @@ describe("runWithModelFallback", () => {
expect(run).toHaveBeenCalledTimes(2);
});
it("keeps provider failover metadata authoritative over nested session locks", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-5.4",
fallbacks: ["anthropic/claude-sonnet-4-6"],
},
},
},
});
const lockError = new SessionWriteLockTimeoutError({
timeoutMs: 10_000,
owner: "pid=37121",
lockPath: "/tmp/openclaw/session.jsonl.lock",
});
const providerError = {
status: 429,
code: "RESOURCE_EXHAUSTED",
message: "upstream quota pressure",
cause: lockError,
};
const run = vi.fn().mockRejectedValueOnce(providerError).mockResolvedValueOnce("fallback ok");
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-5.4",
run,
});
expect(result.result).toBe("fallback ok");
expect(result.provider).toBe("anthropic");
expect(run).toHaveBeenCalledTimes(2);
expect(result.attempts[0]).toMatchObject({
provider: "openai",
model: "gpt-5.4",
reason: "rate_limit",
status: 429,
code: "RESOURCE_EXHAUSTED",
});
});
it("keeps raw provider schema errors in fallback summaries", async () => {
const cfg = makeCfg({
agents: {
@@ -5358,90 +5282,4 @@ describe("runWithImageModelFallback", () => {
});
});
describe("runWithModelFallback preserved prompt errors", () => {
it.each([
{
label: "timeout",
promptError: Object.assign(new Error("request timed out"), { name: "TimeoutError" }),
expected: { message: "request timed out", reason: "timeout", status: 408 },
},
{
label: "rate limit",
promptError: { status: 429, code: "RATE_LIMITED", message: "too many requests" },
expected: {
message: "too many requests",
reason: "rate_limit",
status: 429,
code: "RATE_LIMITED",
},
},
])(
"falls back with the preserved $label prompt error (#99963)",
async ({ promptError, expected }) => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-5.5",
fallbacks: ["anthropic/claude-sonnet-4-6"],
},
},
},
});
const takeoverError = Object.assign(new Error("cleanup takeover"), {
name: "EmbeddedAttemptSessionTakeoverError",
promptError,
});
const run = vi.fn().mockRejectedValueOnce(takeoverError).mockResolvedValueOnce("ok");
const onError = vi.fn();
const result = await runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-5.5",
run,
onError,
});
expect(result.result).toBe("ok");
expect(run).toHaveBeenCalledTimes(2);
expect(result.attempts[0]).toMatchObject({
error: expected.message,
reason: expected.reason,
});
const observedError = onError.mock.calls[0]?.[0]?.error;
expect(observedError).toMatchObject({ name: "FailoverError", ...expected });
expect(observedError).toHaveProperty("cause", takeoverError);
},
);
it("still aborts fallback for a pure takeover error without promptError (regression)", async () => {
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/gpt-5.5",
fallbacks: ["anthropic/claude-sonnet-4-6"],
},
},
},
});
const pureTakeoverError = Object.assign(
new Error("session file changed while embedded prompt lock was released"),
{ name: "EmbeddedAttemptSessionTakeoverError" },
);
const run = vi.fn().mockRejectedValue(pureTakeoverError);
await expect(
runWithModelFallback({
cfg,
provider: "openai",
model: "gpt-5.5",
run,
}),
).rejects.toBe(pureTakeoverError);
expect(run).toHaveBeenCalledTimes(1);
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
-55
View File
@@ -1,55 +0,0 @@
/** Stable session ownership errors shared by runtime and public harness adapters. */
const TIMEOUT_CODE = "OPENCLAW_SESSION_WRITE_LOCK_TIMEOUT";
const STALE_CODE = "OPENCLAW_SESSION_WRITE_LOCK_STALE";
export class SessionWriteLockTimeoutError extends Error {
readonly code = TIMEOUT_CODE;
declare readonly timeoutMs: number;
declare readonly owner: string;
declare readonly lockPath: string;
constructor(params: { timeoutMs: number; owner: string; lockPath: string }) {
super(
`session file locked (timeout ${params.timeoutMs}ms): ${params.owner} ${params.lockPath}`,
);
this.name = "SessionWriteLockTimeoutError";
Object.assign(this, params);
}
}
export class SessionWriteLockStaleError extends Error {
readonly code = STALE_CODE;
declare readonly owner: string;
declare readonly lockPath: string;
declare readonly staleReasons: string[];
constructor(params: { owner: string; lockPath: string; staleReasons?: string[] }) {
const staleReasons = params.staleReasons?.length ? params.staleReasons : ["unknown"];
super(
`session file lock stale (${staleReasons.join(", ")}): ${params.owner} ${params.lockPath}`,
);
this.name = "SessionWriteLockStaleError";
Object.assign(this, params, { staleReasons });
}
}
/** Returns whether another owner replaced the active SQLite session lease. */
export function isSessionWriteLockLeaseLostError(error: unknown): boolean {
const code = (error as { code?: unknown } | null)?.code;
const staleReasons = (error as { staleReasons?: unknown } | null)?.staleReasons;
return (
(error instanceof SessionWriteLockStaleError || code === STALE_CODE) &&
Array.isArray(staleReasons) &&
staleReasons.includes("lease-lost")
);
}
export function isSessionWriteLockAcquireError(error: unknown): boolean {
const code = (error as { code?: unknown } | null)?.code;
return (
error instanceof SessionWriteLockTimeoutError ||
error instanceof SessionWriteLockStaleError ||
code === TIMEOUT_CODE ||
code === STALE_CODE
);
}
@@ -1,28 +0,0 @@
import "./session-write-lock.js";
type SessionWriteLockTestApi = {
resetSessionWriteLockStateForTest(): void;
testing: {
releaseAllLocksSync(): void;
setProcessStartTimeResolverForTest(resolver: ((pid: number) => number | null) | null): void;
};
};
function getTestApi(): SessionWriteLockTestApi {
return (globalThis as Record<PropertyKey, unknown>)[
Symbol.for("openclaw.sessionWriteLockTestApi")
] as SessionWriteLockTestApi;
}
export function resetSessionWriteLockStateForTest(): void {
getTestApi().resetSessionWriteLockStateForTest();
}
export const testing: SessionWriteLockTestApi["testing"] = {
releaseAllLocksSync() {
return getTestApi().testing.releaseAllLocksSync();
},
setProcessStartTimeResolverForTest(resolver) {
return getTestApi().testing.setProcessStartTimeResolverForTest(resolver);
},
};
-287
View File
@@ -1,287 +0,0 @@
import { spawn } from "node:child_process";
import { once } from "node:events";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import { acquireOpenClawStateLease } from "../state/openclaw-state-lease.js";
import { SessionWriteLockTimeoutError } from "./session-write-lock-error.js";
import {
acquireSessionWriteLock,
drainSessionWriteLockStateForTest,
resolveSessionLockMaxHoldFromTimeout,
resolveSessionWriteLockAcquireTimeoutMs,
resolveSessionWriteLockOptions,
resolveSessionWriteLockTargetKey,
} from "./session-write-lock.js";
import { resetSessionWriteLockStateForTest, testing } from "./session-write-lock.test-support.js";
describe("SQLite session write leases", () => {
let root = "";
beforeEach(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-lease-"));
});
afterEach(async () => {
await drainSessionWriteLockStateForTest();
resetSessionWriteLockStateForTest();
closeOpenClawAgentDatabasesForTest();
await fs.rm(root, { recursive: true, force: true });
});
function target(overrides: { agentId?: string; sessionId?: string; storePath?: string } = {}) {
return resolveSessionWriteLockTargetKey({
agentId: overrides.agentId ?? "main",
sessionId: overrides.sessionId ?? "session-a",
sessionKey: "agent:main:ignored-alias",
storePath: overrides.storePath ?? path.join(root, "openclaw-agent.sqlite"),
});
}
async function acquire(
sessionFile = target(),
options: {
timeoutMs?: number;
maxHoldMs?: number;
signal?: AbortSignal;
allowReentrant?: boolean;
} = {},
) {
return await acquireSessionWriteLock({
sessionFile,
targetKind: "session-key",
...options,
});
}
it("serializes writers in SQLite without creating a sidecar", async () => {
const sessionFile = target();
const first = await acquire(sessionFile);
const timeout = await acquire(sessionFile, { timeoutMs: 5 }).catch((error: unknown) => error);
expect(timeout).toBeInstanceOf(SessionWriteLockTimeoutError);
expect(timeout).toMatchObject({
owner: `held by this process (pid ${process.pid}); an earlier run has not released it`,
});
expect(String(timeout)).toContain(`pid ${process.pid}`);
await expect(fs.access(`${sessionFile}.lock`)).rejects.toMatchObject({ code: "ENOENT" });
await first.release();
const second = await acquire(sessionFile, { timeoutMs: 500 });
expect(() => second.assertOwned?.()).not.toThrow();
await second.release();
});
it("reports the live foreign process that holds the session lease", async () => {
const databasePath = path.join(root, "foreign-openclaw-agent.sqlite");
const sessionFile = target({ sessionId: "foreign-session", storePath: databasePath });
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
stdio: "ignore",
});
await once(child, "spawn");
const childPid = child.pid;
if (!childPid) {
throw new Error("foreign lease fixture did not start");
}
const foreignLease = await acquireOpenClawStateLease({
scope: "session-write",
key: sessionFile,
database: { scope: "agent", agentId: "main", path: databasePath },
leaseMs: 1_000,
waitMs: 0,
processOwner: {
pid: childPid,
startTime: null,
isAlive: () => true,
readStartTime: () => null,
},
});
try {
const timeout = await acquire(sessionFile, { timeoutMs: 5 }).catch((error: unknown) => error);
expect(timeout).toBeInstanceOf(SessionWriteLockTimeoutError);
expect(timeout).toMatchObject({ owner: `held by OpenClaw process pid ${childPid}` });
} finally {
await foreignLease.release();
child.kill();
if (child.exitCode === null && child.signalCode === null) {
await once(child, "exit");
}
}
});
it("reference-counts intentional process-local reentrancy", async () => {
const sessionFile = target();
const first = await acquire(sessionFile, { allowReentrant: true });
const second = await acquire(sessionFile, { allowReentrant: true });
await first.release();
expect(() => second.assertOwned?.()).not.toThrow();
await expect(acquire(sessionFile, { timeoutMs: 5 })).rejects.toThrow(/session file locked/);
await second.release();
const next = await acquire(sessionFile, { timeoutMs: 500 });
await next.release();
});
it("coalesces repeated release and never erases a successor", async () => {
const sessionFile = target();
const first = await acquire(sessionFile);
await Promise.all([first.release(), first.release()]);
const successor = await acquire(sessionFile, { timeoutMs: 500 });
await first.release();
expect(() => successor.assertOwned?.()).not.toThrow();
await successor.release();
});
it("uses abort only for admission", async () => {
const alreadyAborted = new AbortController();
const reason = new Error("stop requested");
alreadyAborted.abort(reason);
await expect(acquire(target(), { signal: alreadyAborted.signal })).rejects.toBe(reason);
const admittedAbort = new AbortController();
const lock = await acquire(target({ sessionId: "session-b" }), {
signal: admittedAbort.signal,
});
admittedAbort.abort(new Error("late abort"));
expect(() => lock.assertOwned?.()).not.toThrow();
await expect(lock.release()).resolves.toBeUndefined();
});
it("aborts while waiting without affecting the current owner", async () => {
const sessionFile = target();
const held = await acquire(sessionFile);
const abort = new AbortController();
const reason = new Error("stop waiting");
const pending = acquire(sessionFile, {
timeoutMs: Number.POSITIVE_INFINITY,
signal: abort.signal,
});
const rejected = expect(pending).rejects.toBe(reason);
abort.abort(reason);
await rejected;
expect(() => held.assertOwned?.()).not.toThrow();
await held.release();
const successor = await acquire(sessionFile, { timeoutMs: 500 });
await successor.release();
});
it("does not time-take over a live lease owner", async () => {
testing.setProcessStartTimeResolverForTest(() => null);
const sessionFile = target();
const first = await acquire(sessionFile, { maxHoldMs: 5 });
await new Promise<void>((resolve) => {
setTimeout(resolve, 10);
});
await expect(acquire(sessionFile, { maxHoldMs: 1_000, timeoutMs: 5 })).rejects.toThrow(
/session file locked/,
);
expect(() => first.assertOwned?.()).not.toThrow();
await first.release();
});
it("invalidates held handles during synchronous process cleanup", async () => {
const lock = await acquire();
testing.releaseAllLocksSync();
expect(() => lock.assertOwned?.()).toThrow(/lease-lost/);
await expect(lock.release()).resolves.toBeUndefined();
const next = await acquire(target(), { timeoutMs: 500 });
await next.release();
});
it("namespaces independent transcript targets", async () => {
const main = await acquire(target({ agentId: "main", sessionId: "main-session" }));
const worker = await acquire(
target({
agentId: "worker",
sessionId: "worker-session",
storePath: path.join(root, "worker", "openclaw-agent.sqlite"),
}),
);
await Promise.all([main.release(), worker.release()]);
});
it("uses one key for session aliases and legacy/SQLite store locators", () => {
const base = {
agentId: "main",
sessionId: "shared-session",
sessionKey: "agent:main:alias-a",
};
expect(
resolveSessionWriteLockTargetKey({ ...base, storePath: path.join(root, "sessions.json") }),
).toBe(
resolveSessionWriteLockTargetKey({
...base,
sessionKey: "agent:main:alias-b",
storePath: path.join(root, "openclaw-agent.sqlite"),
}),
);
});
it.runIf(process.platform !== "win32")(
"keeps the key stable when a missing directory appears beneath a symlink",
async () => {
const realRoot = path.join(root, "real");
const linkedRoot = path.join(root, "linked");
await fs.mkdir(realRoot);
await fs.symlink(realRoot, linkedRoot);
const transcript = {
agentId: "main",
sessionId: "shared-session",
sessionKey: "agent:main:main",
storePath: path.join(linkedRoot, "new-agent", "sessions.json"),
};
const before = resolveSessionWriteLockTargetKey(transcript);
await fs.mkdir(path.join(linkedRoot, "new-agent"));
expect(resolveSessionWriteLockTargetKey(transcript)).toBe(before);
},
);
});
describe("session write lease options", () => {
it("derives bounded max hold from timeout plus grace", () => {
expect(resolveSessionLockMaxHoldFromTimeout({ timeoutMs: 600_000 })).toBe(720_000);
expect(resolveSessionLockMaxHoldFromTimeout({ timeoutMs: 1_000, minMs: 5_000 })).toBe(121_000);
expect(resolveSessionLockMaxHoldFromTimeout({ timeoutMs: Number.POSITIVE_INFINITY })).toBe(
MAX_TIMER_TIMEOUT_MS,
);
});
it("uses the built-in acquire timeout", () => {
expect(resolveSessionWriteLockAcquireTimeoutMs()).toBe(60_000);
});
it("accepts decimal emergency overrides", () => {
expect(
resolveSessionWriteLockOptions(undefined, {
env: {
OPENCLAW_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS: "120000",
OPENCLAW_SESSION_WRITE_LOCK_STALE_MS: "60000",
OPENCLAW_SESSION_WRITE_LOCK_MAX_HOLD_MS: "50000",
},
}),
).toEqual({ timeoutMs: 120_000, staleMs: 60_000, maxHoldMs: 50_000 });
});
it("ignores non-decimal, unsafe, and unsupported infinite overrides", () => {
expect(
resolveSessionWriteLockOptions(undefined, {
env: {
OPENCLAW_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS: "Infinity",
OPENCLAW_SESSION_WRITE_LOCK_STALE_MS: "0x1000",
OPENCLAW_SESSION_WRITE_LOCK_MAX_HOLD_MS: "9007199254740993",
},
}),
).toEqual({
timeoutMs: Number.POSITIVE_INFINITY,
staleMs: 30 * 60_000,
maxHoldMs: 5 * 60_000,
});
});
});
-403
View File
@@ -1,403 +0,0 @@
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import type { SessionTranscriptRuntimeTarget } from "../config/sessions/session-accessor.types.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolvePathViaExistingAncestorSync } from "../infra/boundary-path.js";
import { readWindowsProcessStartTimeSync } from "../infra/windows-port-pids.js";
import { LEGACY_IMPLICIT_AGENT_ID, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { getFileLockProcessStartTime, isPidAlive } from "../shared/pid-alive.js";
import {
openOpenClawAgentDatabase,
type OpenClawAgentDatabaseOptions,
} from "../state/openclaw-agent-db.js";
import {
acquireOpenClawStateLease,
OpenClawStateLeaseError,
type OpenClawStateLeaseHandle,
} from "../state/openclaw-state-lease.js";
import {
SessionWriteLockStaleError,
SessionWriteLockTimeoutError,
} from "./session-write-lock-error.js";
const DEFAULT_SESSION_WRITE_LOCK_STALE_MS = 30 * 60 * 1000;
const DEFAULT_SESSION_WRITE_LOCK_MAX_HOLD_MS = 5 * 60 * 1000;
const DEFAULT_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS = 60_000;
const DEFAULT_TIMEOUT_GRACE_MS = 2 * 60 * 1000;
const SESSION_WRITE_LEASE_SCOPE = "session-write";
const SESSION_WRITE_LEASE_STATE_KEY = Symbol.for("openclaw.sessionWriteLeaseState");
const defaultProcessStartTimeForLock = (pid: number): number | null =>
process.platform === "win32"
? readWindowsProcessStartTimeSync(pid, 1_000)
: getFileLockProcessStartTime(pid);
let resolveProcessStartTimeForLock = defaultProcessStartTimeForLock;
type SessionWriteLeaseEntry = {
lease: OpenClawStateLeaseHandle;
refCount: number;
};
const sessionWriteLeaseState = resolveGlobalSingleton(
SESSION_WRITE_LEASE_STATE_KEY,
(): { held: Map<string, SessionWriteLeaseEntry> } => ({ held: new Map() }),
);
export type SessionWriteLockAcquireTimeoutConfig = OpenClawConfig;
type SessionWriteLockMsKey = "acquireTimeoutMs" | "staleMs" | "maxHoldMs";
const SESSION_WRITE_LOCK_ENV: Record<SessionWriteLockMsKey, string> = {
acquireTimeoutMs: "OPENCLAW_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS",
staleMs: "OPENCLAW_SESSION_WRITE_LOCK_STALE_MS",
maxHoldMs: "OPENCLAW_SESSION_WRITE_LOCK_MAX_HOLD_MS",
};
function parsePositiveMs(
value: number | undefined,
options: { allowInfinity?: boolean } = {},
): number | undefined {
if (typeof value !== "number" || Number.isNaN(value) || value <= 0) {
return undefined;
}
if (value === Number.POSITIVE_INFINITY) {
return options.allowInfinity ? value : undefined;
}
return Number.isSafeInteger(value) ? value : undefined;
}
function resolvePositiveMs(
value: number | undefined,
fallback: number,
options: { allowInfinity?: boolean } = {},
): number {
if (value === Number.POSITIVE_INFINITY) {
return options.allowInfinity ? value : fallback;
}
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
function readPositiveMsEnv(
env: NodeJS.ProcessEnv,
key: string,
options: { allowInfinity?: boolean } = {},
): number | undefined {
const raw = env[key]?.trim();
if (!raw) {
return undefined;
}
if (raw === "Infinity") {
return options.allowInfinity ? Number.POSITIVE_INFINITY : undefined;
}
return /^\d+$/.test(raw) ? parsePositiveMs(Number(raw), options) : undefined;
}
function resolveSessionWriteLockMs(params: {
env?: NodeJS.ProcessEnv;
key: SessionWriteLockMsKey;
fallback: number;
allowInfinity?: boolean;
}): number {
return (
readPositiveMsEnv(params.env ?? process.env, SESSION_WRITE_LOCK_ENV[params.key], {
allowInfinity: params.allowInfinity,
}) ?? params.fallback
);
}
export function resolveSessionWriteLockAcquireTimeoutMs(
_config?: SessionWriteLockAcquireTimeoutConfig,
env?: NodeJS.ProcessEnv,
): number {
return resolveSessionWriteLockMs({
env,
key: "acquireTimeoutMs",
fallback: DEFAULT_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS,
allowInfinity: true,
});
}
export function resolveSessionWriteLockStaleMs(
_config?: SessionWriteLockAcquireTimeoutConfig,
env?: NodeJS.ProcessEnv,
): number {
return resolveSessionWriteLockMs({
env,
key: "staleMs",
fallback: DEFAULT_SESSION_WRITE_LOCK_STALE_MS,
});
}
function resolveSessionWriteLockMaxHoldMs(
_config?: SessionWriteLockAcquireTimeoutConfig,
params: { env?: NodeJS.ProcessEnv; fallback?: number } = {},
): number {
return resolveSessionWriteLockMs({
env: params.env,
key: "maxHoldMs",
fallback: params.fallback ?? DEFAULT_SESSION_WRITE_LOCK_MAX_HOLD_MS,
});
}
export function resolveSessionWriteLockOptions(
config?: SessionWriteLockAcquireTimeoutConfig,
params: { env?: NodeJS.ProcessEnv; maxHoldMsFallback?: number } = {},
): { timeoutMs: number; staleMs: number; maxHoldMs: number } {
return {
timeoutMs: resolveSessionWriteLockAcquireTimeoutMs(config, params.env),
staleMs: resolveSessionWriteLockStaleMs(config, params.env),
maxHoldMs: resolveSessionWriteLockMaxHoldMs(config, {
env: params.env,
fallback: params.maxHoldMsFallback,
}),
};
}
export function resolveSessionLockMaxHoldFromTimeout(params: {
timeoutMs: number;
graceMs?: number;
minMs?: number;
}): number {
const minMs = resolvePositiveMs(params.minMs, DEFAULT_SESSION_WRITE_LOCK_MAX_HOLD_MS);
const timeoutMs = resolvePositiveMs(params.timeoutMs, minMs, { allowInfinity: true });
if (timeoutMs === Number.POSITIVE_INFINITY) {
return MAX_TIMER_TIMEOUT_MS;
}
const graceMs = resolvePositiveMs(params.graceMs, DEFAULT_TIMEOUT_GRACE_MS);
return Math.min(MAX_TIMER_TIMEOUT_MS, Math.max(minMs, timeoutMs + graceMs));
}
export function resolveSessionWriteLockTargetKey(target: SessionTranscriptRuntimeTarget): string {
const databaseTarget = resolveSqliteTargetFromSessionStorePath(target.storePath, {
agentId: target.agentId,
});
return JSON.stringify([
target.agentId,
resolvePathViaExistingAncestorSync(databaseTarget.path),
target.sessionId,
]);
}
function resolveSessionWriteLeaseDatabaseOptions(sessionKey: string): OpenClawAgentDatabaseOptions {
let parsed: unknown;
try {
parsed = JSON.parse(sessionKey) as unknown;
} catch {
// Shipped unqualified harness keys use their agent's canonical database.
}
if (
Array.isArray(parsed) &&
parsed.length === 3 &&
parsed.every((value) => typeof value === "string" && value.trim().length > 0)
) {
const [agentId, storePath] = parsed as [string, string, string];
const target = resolveSqliteTargetFromSessionStorePath(storePath, { agentId });
return { agentId: target.agentId ?? agentId, path: target.path };
}
const agentId = resolveAgentIdFromSessionKey(sessionKey, LEGACY_IMPLICIT_AGENT_ID);
return { agentId, path: openOpenClawAgentDatabase({ agentId }).path };
}
function leaseDurationMs(value: number, minimum: number): number {
if (!Number.isFinite(value)) {
return MAX_TIMER_TIMEOUT_MS;
}
return Math.min(MAX_TIMER_TIMEOUT_MS, Math.max(minimum, Math.floor(value)));
}
function leasePath(sessionKey: string): string {
return `sqlite:${SESSION_WRITE_LEASE_SCOPE}:${sessionKey}`;
}
function leaseLost(sessionKey: string): SessionWriteLockStaleError {
return new SessionWriteLockStaleError({
owner: "expired or replaced SQLite lease",
lockPath: leasePath(sessionKey),
staleReasons: ["lease-lost"],
});
}
function describeLeaseTimeoutOwner(error: OpenClawStateLeaseError): string {
const payload = error.ownerPayload;
if (!payload) {
return "another OpenClaw process";
}
if (payload.pid === process.pid) {
return `held by this process (pid ${payload.pid}); an earlier run has not released it`;
}
if (!isPidAlive(payload.pid)) {
return "another OpenClaw process";
}
const observedStartTime = resolveProcessStartTimeForLock(payload.pid);
if (
payload.starttime !== undefined &&
observedStartTime !== null &&
payload.starttime !== observedStartTime
) {
return "another OpenClaw process";
}
return `held by OpenClaw process pid ${payload.pid}`;
}
function createLeaseHandle(sessionKey: string, entry: SessionWriteLeaseEntry) {
let released = false;
let releasePromise: Promise<void> | undefined;
return {
assertOwned: () => {
if (sessionWriteLeaseState.held.get(sessionKey) !== entry) {
throw leaseLost(sessionKey);
}
try {
entry.lease.assertOwned();
} catch (error) {
if (
error instanceof OpenClawStateLeaseError &&
error.code === "OPENCLAW_STATE_LEASE_LOST"
) {
throw leaseLost(sessionKey);
}
throw error;
}
},
release: () => {
if (released) {
return Promise.resolve();
}
releasePromise ??= (async () => {
const current = sessionWriteLeaseState.held.get(sessionKey);
if (current !== entry) {
return;
}
if (current.refCount > 1) {
current.refCount -= 1;
return;
}
sessionWriteLeaseState.held.delete(sessionKey);
try {
await current.lease.release();
} catch (error) {
if (!sessionWriteLeaseState.held.has(sessionKey)) {
sessionWriteLeaseState.held.set(sessionKey, current);
}
throw error;
}
})().then(
() => {
released = true;
},
(error: unknown) => {
releasePromise = undefined;
throw error;
},
);
return releasePromise;
},
};
}
export async function acquireSessionWriteLock(params: {
sessionFile: string;
targetKind: "session-key";
timeoutMs?: number;
staleMs?: number;
maxHoldMs?: number;
signal?: AbortSignal;
allowReentrant?: boolean;
reentrantOwner?: never;
}): Promise<{ assertOwned?: () => void; release: () => Promise<void> }> {
if (params.signal?.aborted) {
throw params.signal.reason;
}
const defaults = resolveSessionWriteLockOptions();
const timeoutMs = resolvePositiveMs(params.timeoutMs, defaults.timeoutMs, {
allowInfinity: true,
});
const maxHoldMs = resolvePositiveMs(params.maxHoldMs, defaults.maxHoldMs);
const existing = sessionWriteLeaseState.held.get(params.sessionFile);
if (params.allowReentrant === true && existing) {
existing.refCount += 1;
return createLeaseHandle(params.sessionFile, existing);
}
const databaseOptions = resolveSessionWriteLeaseDatabaseOptions(params.sessionFile);
openOpenClawAgentDatabase(databaseOptions);
const admission = new AbortController();
const abortAdmission = () => admission.abort(params.signal?.reason);
params.signal?.addEventListener("abort", abortAdmission, { once: true });
try {
const lease = await acquireOpenClawStateLease({
scope: SESSION_WRITE_LEASE_SCOPE,
key: params.sessionFile,
database: {
scope: "agent",
agentId: databaseOptions.agentId,
...(databaseOptions.path ? { path: databaseOptions.path } : {}),
},
leaseMs: leaseDurationMs(maxHoldMs, 1_000),
waitMs: leaseDurationMs(timeoutMs, 0),
signal: admission.signal,
leaseLabel: "session write lease",
operationLabel: "session.write-lease",
strictRelease: true,
processOwner: {
pid: process.pid,
startTime: resolveProcessStartTimeForLock(process.pid),
isAlive: isPidAlive,
readStartTime: resolveProcessStartTimeForLock,
},
});
params.signal?.removeEventListener("abort", abortAdmission);
const entry = { lease, refCount: 1 };
sessionWriteLeaseState.held.set(params.sessionFile, entry);
return createLeaseHandle(params.sessionFile, entry);
} catch (error) {
if (params.signal?.aborted) {
throw params.signal.reason;
}
if (error instanceof OpenClawStateLeaseError && error.code === "OPENCLAW_STATE_LEASE_TIMEOUT") {
throw new SessionWriteLockTimeoutError({
timeoutMs,
owner: describeLeaseTimeoutOwner(error),
lockPath: leasePath(params.sessionFile),
});
}
throw error;
} finally {
params.signal?.removeEventListener("abort", abortAdmission);
}
}
function releaseAllSessionWriteLeasesSynchronously(): void {
for (const entry of sessionWriteLeaseState.held.values()) {
try {
entry.lease.releaseSynchronously();
} catch {
// A dead process owner remains reclaimable by pid/start-time identity.
}
}
sessionWriteLeaseState.held.clear();
}
export async function drainSessionWriteLockStateForTest(): Promise<void> {
const held = [...sessionWriteLeaseState.held.values()];
sessionWriteLeaseState.held.clear();
await Promise.all(held.map((entry) => entry.lease.release().catch(() => undefined)));
}
function resetSessionWriteLockStateForTest(): void {
releaseAllSessionWriteLeasesSynchronously();
resolveProcessStartTimeForLock = defaultProcessStartTimeForLock;
}
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.sessionWriteLockTestApi")] = {
resetSessionWriteLockStateForTest,
testing: {
releaseAllLocksSync: releaseAllSessionWriteLeasesSynchronously,
setProcessStartTimeResolverForTest(resolver: ((pid: number) => number | null) | null): void {
resolveProcessStartTimeForLock = resolver ?? defaultProcessStartTimeForLock;
},
},
};
}
+11 -9
View File
@@ -13,7 +13,7 @@ import type {
AgentSessionConfig,
AgentSessionEvent,
AgentSessionEventListener,
AgentSessionWriteLockRunner,
AgentSessionWriteSettlementRunner,
} from "./agent-session-types.js";
import { extractTextContent } from "./agent-session-utils.js";
import { formatNoApiKeyFoundMessage } from "./auth-guidance.js";
@@ -109,7 +109,7 @@ export abstract class AgentSessionBase {
protected disableBuiltInTools: boolean;
protected baseToolsOverride?: Record<string, AgentTool>;
protected sessionStartEvent: SessionStartEvent;
protected withExternalSessionWriteLock?: AgentSessionWriteLockRunner;
protected withExternalSessionWriteSettlement?: AgentSessionWriteSettlementRunner;
protected extensionUIContext?: ExtensionUIContext;
protected extensionCommandContextActions?: ExtensionCommandContextActions;
protected extensionAbortHandler?: () => void;
@@ -150,7 +150,7 @@ export abstract class AgentSessionBase {
type: "session_start",
reason: "startup",
};
this.withExternalSessionWriteLock = config.withSessionWriteLock;
this.withExternalSessionWriteSettlement = config.withSessionWriteSettlement;
this.contextOverflowRecoveryOwner = config.contextOverflowRecoveryOwner ?? "session";
}
@@ -200,9 +200,9 @@ export abstract class AgentSessionBase {
return result.ok ? { apiKey: result.apiKey, headers: result.headers } : {};
}
protected async runWithSessionWriteLock<T>(run: () => Promise<T> | T): Promise<T> {
return this.withExternalSessionWriteLock
? await this.withExternalSessionWriteLock(run)
protected async runWithSessionWriteSettlement<T>(run: () => Promise<T> | T): Promise<T> {
return this.withExternalSessionWriteSettlement
? await this.withExternalSessionWriteSettlement(run)
: await run();
}
@@ -221,7 +221,7 @@ export abstract class AgentSessionBase {
protected installAgentToolHooks(): void {
this.agent.beforeToolCall = async ({ toolCall, args }) => {
const runner = this.currentExtensionRunner;
return await this.runWithSessionWriteLock(async () => {
return await this.runWithSessionWriteSettlement(async () => {
if (!runner.hasHandlers("tool_call")) {
return undefined;
}
@@ -248,7 +248,7 @@ export abstract class AgentSessionBase {
return undefined;
}
const hookResult = await this.runWithSessionWriteLock(
const hookResult = await this.runWithSessionWriteSettlement(
async () =>
await runner.emitToolResult({
type: "tool_result",
@@ -323,7 +323,9 @@ export abstract class AgentSessionBase {
(reason as { turnHandoff?: unknown }).turnHandoff === true;
}
if (this.eventMayWriteSession(event)) {
await this.runWithSessionWriteLock(async () => await this.handleAgentEventUnlocked(event));
await this.runWithSessionWriteSettlement(
async () => await this.handleAgentEventUnlocked(event),
);
return;
}
await this.handleAgentEventUnlocked(event);
@@ -41,18 +41,19 @@ export abstract class AgentSessionCompaction extends AgentSessionInspection {
* @param customInstructions Optional instructions for the compaction summary
*/
async compact(customInstructions?: string): Promise<CompactionResult> {
return await this.runWithSessionWriteLock(
async () => await this.compactWithSessionWriteLock(customInstructions, "none"),
return await this.runWithSessionWriteSettlement(
async () => await this.compactWithSessionWriteSettlement(customInstructions, "none"),
);
}
async [agentSessionAutomaticCompaction](customInstructions?: string): Promise<CompactionResult> {
return await this.runWithSessionWriteLock(
async () => await this.compactWithSessionWriteLock(customInstructions, "retry-invalid-once"),
return await this.runWithSessionWriteSettlement(
async () =>
await this.compactWithSessionWriteSettlement(customInstructions, "retry-invalid-once"),
);
}
private async compactWithSessionWriteLock(
private async compactWithSessionWriteSettlement(
customInstructions?: string,
summaryOutputPolicy: SummaryOutputPolicy = "none",
): Promise<CompactionResult> {
+3 -3
View File
@@ -53,7 +53,7 @@ export type AgentSessionEvent =
| { type: "auto_retry_end"; success: boolean; attempt: number; finalError?: string };
export type AgentSessionEventListener = (event: AgentSessionEvent) => unknown;
export type AgentSessionWriteLockRunner = <T>(run: () => Promise<T> | T) => Promise<T>;
export type AgentSessionWriteSettlementRunner = <T>(run: () => Promise<T> | T) => Promise<T>;
export interface AgentSessionConfig {
agent: Agent;
@@ -80,8 +80,8 @@ export interface AgentSessionConfig {
extensionRunnerRef?: { current?: ExtensionRunner };
/** Session start metadata emitted when extensions bind to this runtime. */
sessionStartEvent?: SessionStartEvent;
/** Lock used before session-file writes or write-capable hooks. */
withSessionWriteLock?: AgentSessionWriteLockRunner;
/** Settlement boundary for session writes and write-capable hooks. */
withSessionWriteSettlement?: AgentSessionWriteSettlementRunner;
/** Owner of reactive context-overflow recovery. Defaults to the session. */
contextOverflowRecoveryOwner?: "session" | "caller";
}
+19 -19
View File
@@ -1,7 +1,7 @@
import path from "node:path";
import { createAssistantMessageEventStream, type AssistantMessage } from "openclaw/plugin-sdk/llm";
// Agent session SDK tests cover default tool wiring, prompt preservation, and
// session write-lock behavior.
// session write-settlement behavior.
import { Type } from "typebox";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
@@ -675,9 +675,9 @@ describe("createAgentSession tool defaults", () => {
expect(exactPromptOptions.promptGuidelines).toEqual(["Use custom_lookup for test values."]);
});
it("runs session message persistence under the configured write lock", async () => {
// Transcript writes share the caller-provided lock so concurrent event
// handlers cannot interleave JSONL persistence.
it("runs session message persistence under the configured write settlement", async () => {
// Transcript writes share the caller-provided settlement boundary so
// concurrent event handlers cannot interleave persistence.
const events: string[] = [];
const sessionManager = SessionManager.inMemory();
const { session } = await createAgentSession({
@@ -686,12 +686,12 @@ describe("createAgentSession tool defaults", () => {
sessionManager,
settingsManager: SettingsManager.inMemory(),
modelRegistry: ModelRegistry.inMemory(AuthStorage.inMemory()),
withSessionWriteLock: async (run) => {
events.push("lock:start");
withSessionWriteSettlement: async (run) => {
events.push("settlement:start");
try {
return await run();
} finally {
events.push("lock:end");
events.push("settlement:end");
}
},
});
@@ -709,11 +709,11 @@ describe("createAgentSession tool defaults", () => {
},
});
expect(events).toEqual(["lock:start", "lock:end"]);
expect(events).toEqual(["settlement:start", "settlement:end"]);
expect(sessionManager.getEntries().some((entry) => entry.type === "message")).toBe(true);
});
it("runs write-capable tool hooks under the configured write lock", async () => {
it("runs write-capable tool hooks under the configured write settlement", async () => {
const events: string[] = [];
const handlers = new Map<string, Array<(...args: unknown[]) => Promise<unknown>>>([
[
@@ -733,12 +733,12 @@ describe("createAgentSession tool defaults", () => {
sessionManager: SessionManager.inMemory(),
settingsManager: SettingsManager.inMemory(),
modelRegistry: ModelRegistry.inMemory(AuthStorage.inMemory()),
withSessionWriteLock: async (run) => {
events.push("lock:start");
withSessionWriteSettlement: async (run) => {
events.push("settlement:start");
try {
return await run();
} finally {
events.push("lock:end");
events.push("settlement:end");
}
},
});
@@ -770,12 +770,12 @@ describe("createAgentSession tool defaults", () => {
},
});
expect(events).toEqual(["lock:start", "hook", "lock:end"]);
expect(events).toEqual(["settlement:start", "hook", "settlement:end"]);
});
it("fences tool execution when no extension hook is registered", async () => {
// Write-capable tools still enter the lock even without hooks; the lock is
// about shared session state, not just extension execution.
// Write-capable tools still enter the settlement boundary even without hooks;
// it covers shared session state, not just extension execution.
const events: string[] = [];
const { session } = await createAgentSession({
model: testModel,
@@ -783,12 +783,12 @@ describe("createAgentSession tool defaults", () => {
sessionManager: SessionManager.inMemory(),
settingsManager: SettingsManager.inMemory(),
modelRegistry: ModelRegistry.inMemory(AuthStorage.inMemory()),
withSessionWriteLock: async (run) => {
events.push("lock:start");
withSessionWriteSettlement: async (run) => {
events.push("settlement:start");
try {
return await run();
} finally {
events.push("lock:end");
events.push("settlement:end");
}
},
});
@@ -820,7 +820,7 @@ describe("createAgentSession tool defaults", () => {
},
});
expect(events).toEqual(["lock:start", "lock:end"]);
expect(events).toEqual(["settlement:start", "settlement:end"]);
});
});
+10 -8
View File
@@ -26,7 +26,7 @@ import {
type InternalBeforeToolBatchHook,
} from "../runtime/internal-hooks.js";
import type { AgentSessionConfig } from "./agent-session-types.js";
import { AgentSession, type AgentSessionWriteLockRunner } from "./agent-session.js";
import { AgentSession, type AgentSessionWriteSettlementRunner } from "./agent-session.js";
import { formatNoModelsAvailableMessage } from "./auth-guidance.js";
import { AuthStorage } from "./auth-storage.js";
import { DEFAULT_THINKING_LEVEL } from "./defaults.js";
@@ -123,8 +123,8 @@ export interface CreateAgentSessionOptions {
settingsManager?: SettingsManager;
/** Session start event metadata for extension runtime startup. */
sessionStartEvent?: SessionStartEvent;
/** Optional lock used before session-file writes or write-capable extension hooks. */
withSessionWriteLock?: AgentSessionWriteLockRunner;
/** Optional settlement boundary for session writes and write-capable extension hooks. */
withSessionWriteSettlement?: AgentSessionWriteSettlementRunner;
}
type CreateAgentSessionInternalOptions = Pick<
@@ -463,8 +463,10 @@ async function createAgentSessionImpl(
};
const extensionRunnerRef: { current?: ExtensionRunner } = {};
const runWithSessionWriteLock = async <T>(run: () => Promise<T> | T): Promise<T> =>
options.withSessionWriteLock ? await options.withSessionWriteLock(run) : await run();
const runWithSessionWriteSettlement = async <T>(run: () => Promise<T> | T): Promise<T> =>
options.withSessionWriteSettlement
? await options.withSessionWriteSettlement(run)
: await run();
const modelRegistryRuntime = getModelRegistryRuntime(modelRegistry);
const agent: Agent = new Agent({
@@ -500,7 +502,7 @@ async function createAgentSessionImpl(
if (!runner?.hasHandlers("before_provider_request")) {
return payload;
}
return await runWithSessionWriteLock(
return await runWithSessionWriteSettlement(
async () => await runner.emitBeforeProviderRequest(payload),
);
},
@@ -510,7 +512,7 @@ async function createAgentSessionImpl(
if (!runner?.hasHandlers("after_provider_response")) {
return;
}
await runWithSessionWriteLock(
await runWithSessionWriteSettlement(
async () =>
await runner.emit({
type: "after_provider_response",
@@ -568,7 +570,7 @@ async function createAgentSessionImpl(
disableBuiltInTools,
extensionRunnerRef,
sessionStartEvent: options.sessionStartEvent,
withSessionWriteLock: options.withSessionWriteLock,
withSessionWriteSettlement: options.withSessionWriteSettlement,
contextOverflowRecoveryOwner: internalOptions.contextOverflowRecoveryOwner,
});
const extensionsResult = resourceLoader.getExtensions();
@@ -26,8 +26,8 @@ type DeliveryDeps = {
type Testing = {
setDepsForTest(overrides?: Partial<DeliveryDeps>): void;
hasAnnounceSendEvidence(error: unknown): boolean;
hasSessionFileChangedAnnounceError(error: unknown): boolean;
isSessionFileChangedAnnounceError(message: string): boolean;
hasWriterClaimReboundAnnounceError(error: unknown): boolean;
isWriterClaimReboundAnnounceError(error: unknown): boolean;
};
function getTesting(): Testing {
@@ -39,8 +39,8 @@ function getTesting(): Testing {
export const testing: Testing = {
setDepsForTest: (overrides) => getTesting().setDepsForTest(overrides),
hasAnnounceSendEvidence: (error) => getTesting().hasAnnounceSendEvidence(error),
hasSessionFileChangedAnnounceError: (error) =>
getTesting().hasSessionFileChangedAnnounceError(error),
isSessionFileChangedAnnounceError: (message) =>
getTesting().isSessionFileChangedAnnounceError(message),
hasWriterClaimReboundAnnounceError: (error) =>
getTesting().hasWriterClaimReboundAnnounceError(error),
isWriterClaimReboundAnnounceError: (error) =>
getTesting().isWriterClaimReboundAnnounceError(error),
};
+32 -78
View File
@@ -2431,7 +2431,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
name: "does not deliver a failure notice after ambiguous persistence failure",
createCallGateway: () =>
vi.fn(async () => {
throw new Error("SessionWriteLockTimeoutError: session file locked before agent run");
throw new Error("gateway persistence failed before agent run");
}) as unknown as typeof runtimeCallGateway,
event: {
childSessionKey: "music_generate:task-failed",
@@ -2727,11 +2727,9 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
);
});
it("keeps generated media queued for the session agent after a requester handoff lock", async () => {
it("keeps generated media queued after requester handoff fails", async () => {
const callGateway = vi.fn(async () => {
throw new Error(
"SessionWriteLockTimeoutError: session file locked (timeout 60000ms): pid=43",
);
throw new Error("requester handoff failed before dispatch");
}) as unknown as typeof runtimeCallGateway;
const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeSequenceMock([
"transcript_commit_wait_unsupported",
@@ -3790,15 +3788,18 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
expect(agentParams.sourceReplyDeliveryMode).toBeUndefined();
});
it("does not retry session-file-changed failures with send evidence", async () => {
it("does not retry writer-claim rebound failures with send evidence", async () => {
const sendErr = new OutboundDeliveryError("outbound delivery failed", {
cause: new Error("outbound delivery failed"),
results: [{ channel: "telegram", messageId: "msg-1" }],
});
const callGateway: typeof runtimeCallGateway = vi.fn(async () => {
throw new Error("session file changed while embedded prompt lock was released", {
cause: sendErr,
});
throw Object.assign(
new Error("session writer claim changed before transcript persistence", {
cause: sendErr,
}),
{ name: "SessionTranscriptWriterClaimReboundError" },
);
});
const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeSequenceMock(["no_active_run"]);
const result = await deliverSlackChannelAnnouncement({
@@ -3817,41 +3818,7 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledTimes(1);
});
it("does not fallback-steer after wrapped prompt-lock takeover with send evidence", async () => {
const takeoverErr = Object.assign(
new Error("session file changed while embedded prompt lock was released: /tmp/session.jsonl"),
{ name: "EmbeddedAttemptSessionTakeoverError" },
);
const promptErr = Object.assign(new Error("some model error"), { visibleReplySent: true });
const wrapperErr = Object.assign(new Error("some model error", { cause: takeoverErr }), {
name: "EmbeddedAttemptSessionTakeoverError",
cleanupError: takeoverErr,
promptError: promptErr,
});
const callGateway: typeof runtimeCallGateway = vi.fn(async () => {
throw wrapperErr;
});
const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeSequenceMock(["no_active_run"]);
const result = await deliverSlackChannelAnnouncement({
callGateway,
queueEmbeddedAgentMessageWithOutcome,
sessionId: "requester-session-lock-race-wrapped-evidence",
isActive: true,
directIdempotencyKey: "announce-permanent-wrapped-lock-error-evidence",
});
expect(result.delivered).toBe(false);
expect(result.path).toBe("direct");
expect(result.error).toBe("some model error");
expect(result.disposition).toBe("ambiguous");
expect(result.phases?.map((phase) => phase.phase)).toEqual(["direct-primary"]);
expect(callGateway).toHaveBeenCalledTimes(1);
expect(queueEmbeddedAgentMessageWithOutcome).toHaveBeenCalledTimes(1);
});
it("retries session-file-changed failures without send evidence", async () => {
it("retries writer-claim rebound failures without send evidence", async () => {
let attempts = 0;
const callGatewaySpy = vi.fn();
const callGateway: typeof runtimeCallGateway = async <
@@ -3860,7 +3827,10 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
callGatewaySpy();
attempts++;
if (attempts <= 1) {
throw new Error("session file changed while embedded prompt lock was released");
throw Object.assign(
new Error("session writer claim changed before transcript persistence"),
{ name: "SessionTranscriptWriterClaimReboundError" },
);
}
return {
result: {
@@ -3932,63 +3902,47 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
expect(sendMessage).not.toHaveBeenCalled();
});
it("detects send evidence from OutboundDeliveryError in the error chain", () => {
const err = new Error(
"session file changed while embedded prompt lock was released: /tmp/session.jsonl",
{
it("detects send evidence from OutboundDeliveryError in a writer rebound chain", () => {
const err = Object.assign(
new Error("session writer claim changed before transcript persistence", {
cause: new OutboundDeliveryError("outbound delivery failed", {
cause: new Error("outbound delivery failed"),
results: [{ channel: "telegram", messageId: "msg-1" }],
}),
},
}),
{ name: "SessionTranscriptWriterClaimReboundError" },
);
expect(testing.isSessionFileChangedAnnounceError(err.message)).toBe(true);
expect(testing.isWriterClaimReboundAnnounceError(err)).toBe(true);
expect(testing.hasAnnounceSendEvidence(err)).toBe(true);
});
it("classifies session-file-changed error as no-send-evidence when the error chain has no send markers", () => {
const err = new Error(
"session file changed while embedded prompt lock was released: /tmp/session.jsonl",
it("classifies writer rebound without send markers as no-send-evidence", () => {
const err = Object.assign(
new Error("session writer claim changed before transcript persistence"),
{ name: "SessionTranscriptWriterClaimReboundError" },
);
expect(testing.isSessionFileChangedAnnounceError(err.message)).toBe(true);
expect(testing.isWriterClaimReboundAnnounceError(err)).toBe(true);
expect(testing.hasAnnounceSendEvidence(err)).toBe(false);
});
it("detects send evidence from visibleReplySent flag on session-file-changed error", () => {
it("detects send evidence from visibleReplySent on writer rebound", () => {
const err = Object.assign(
new Error("session file changed while embedded prompt lock was released: /tmp/session.jsonl"),
{ visibleReplySent: true },
new Error("session writer claim changed before transcript persistence"),
{ name: "SessionTranscriptWriterClaimReboundError", visibleReplySent: true },
);
expect(testing.hasAnnounceSendEvidence(err)).toBe(true);
});
it("detects send evidence from sentBeforeError flag on session-file-changed error", () => {
it("detects send evidence from sentBeforeError on writer rebound", () => {
const err = Object.assign(
new Error("session file changed while embedded prompt lock was released: /tmp/session.jsonl"),
{ sentBeforeError: true },
new Error("session writer claim changed before transcript persistence"),
{ name: "SessionTranscriptWriterClaimReboundError", sentBeforeError: true },
);
expect(testing.hasAnnounceSendEvidence(err)).toBe(true);
});
it("detects send evidence recursively through promptError", () => {
const takeoverErr = Object.assign(
new Error("session file changed while embedded prompt lock was released: /tmp/session.jsonl"),
{ name: "EmbeddedAttemptSessionTakeoverError" },
);
const promptErr = Object.assign(new Error("some model error"), { visibleReplySent: true });
const wrapperErr = Object.assign(new Error("some model error", { cause: takeoverErr }), {
name: "EmbeddedAttemptSessionTakeoverError",
promptError: promptErr,
});
expect(testing.hasAnnounceSendEvidence(wrapperErr)).toBe(true);
expect(testing.hasSessionFileChangedAnnounceError(wrapperErr)).toBe(true);
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+19 -22
View File
@@ -373,8 +373,8 @@ const TRANSIENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [
/\b(econnreset|econnrefused|etimedout|enotfound|ehostunreach|network error)\b/i,
];
const SESSION_FILE_CHANGED_ANNOUNCE_RE =
/session file changed while embedded prompt lock was released/i;
const WRITER_CLAIM_REBOUND_ANNOUNCE_RE =
/session writer claim changed before transcript persistence/i;
const PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [
/unsupported channel/i,
@@ -386,20 +386,19 @@ const PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS: readonly RegExp[] = [
/forbidden: bot was kicked/i,
/recipient is not a valid/i,
/outbound not configured for channel/i,
SESSION_FILE_CHANGED_ANNOUNCE_RE,
WRITER_CLAIM_REBOUND_ANNOUNCE_RE,
];
function isSessionFileChangedAnnounceError(message: string): boolean {
return SESSION_FILE_CHANGED_ANNOUNCE_RE.test(message);
function isWriterClaimReboundAnnounceError(error: unknown): boolean {
return Boolean(
(error &&
typeof error === "object" &&
(error as { name?: unknown }).name === "SessionTranscriptWriterClaimReboundError") ||
WRITER_CLAIM_REBOUND_ANNOUNCE_RE.test(summarizeDeliveryError(error)),
);
}
const ANNOUNCE_ERROR_CHAIN_KEYS = [
"cause",
"cleanupError",
"error",
"promptError",
"reason",
] as const;
const ANNOUNCE_ERROR_CHAIN_KEYS = ["cause", "error", "reason"] as const;
type AnnounceErrorChainKey = (typeof ANNOUNCE_ERROR_CHAIN_KEYS)[number];
type AnnounceErrorRecord = Partial<Record<AnnounceErrorChainKey, unknown>> & {
sentBeforeError?: unknown;
@@ -429,10 +428,8 @@ function hasAnnounceErrorMatch(
return ANNOUNCE_ERROR_CHAIN_KEYS.some((key) => hasAnnounceErrorMatch(error[key], matches, seen));
}
function hasSessionFileChangedAnnounceError(error: unknown): boolean {
return hasAnnounceErrorMatch(error, (candidate) =>
isSessionFileChangedAnnounceError(summarizeDeliveryError(candidate)),
);
function hasWriterClaimReboundAnnounceError(error: unknown): boolean {
return hasAnnounceErrorMatch(error, isWriterClaimReboundAnnounceError);
}
function isTransientAnnounceDeliveryError(error: unknown): boolean {
@@ -440,12 +437,12 @@ function isTransientAnnounceDeliveryError(error: unknown): boolean {
const topLevelPermanent = Boolean(
message && PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message)),
);
if (topLevelPermanent && !isSessionFileChangedAnnounceError(message)) {
if (topLevelPermanent && !isWriterClaimReboundAnnounceError(error)) {
return false;
}
const sessionFileChanged = hasSessionFileChangedAnnounceError(error);
if (sessionFileChanged) {
const writerClaimRebound = hasWriterClaimReboundAnnounceError(error);
if (writerClaimRebound) {
return !hasAnnounceSendEvidence(error);
}
@@ -474,7 +471,7 @@ function isPermanentAnnounceDeliveryError(error: unknown): boolean {
const message = summarizeDeliveryError(error);
return (
(message && PERMANENT_ANNOUNCE_DELIVERY_ERROR_PATTERNS.some((re) => re.test(message))) ||
hasSessionFileChangedAnnounceError(error)
hasWriterClaimReboundAnnounceError(error)
);
}
@@ -1568,8 +1565,8 @@ const testing = {
: defaultSubagentAnnounceDeliveryDeps;
},
hasAnnounceSendEvidence,
hasSessionFileChangedAnnounceError,
isSessionFileChangedAnnounceError,
hasWriterClaimReboundAnnounceError,
isWriterClaimReboundAnnounceError,
};
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[
+13 -13
View File
@@ -3,7 +3,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveSessionStorePathForScope } from "../config/sessions/session-store-path.js";
import {
runWithOwnedSessionTranscriptWriteLock,
runWithOwnedSessionTranscriptWrite,
withOwnedSessionTranscriptWrites,
} from "../config/sessions/transcript-write-context.js";
import type { CallGatewayOptions } from "../gateway/call.js";
@@ -564,9 +564,9 @@ describe("subagent registry lifecycle hardening", () => {
const cleanupReady = new Promise<void>((resolve) => {
releaseCleanup = resolve;
});
const staleWriteLock = vi.fn();
const withStaleWriteLock = async <T>(operation: () => Promise<T> | T): Promise<T> => {
staleWriteLock();
const requesterTranscriptWrite = vi.fn();
const withRequesterTranscriptWrite = async <T>(operation: () => Promise<T> | T): Promise<T> => {
requesterTranscriptWrite();
if (disposed) {
throw new Error("attempt disposed before transcript write");
}
@@ -575,13 +575,13 @@ describe("subagent registry lifecycle hardening", () => {
const freshTranscriptWrite = vi.fn(async () => {});
const runSubagentAnnounceFlow = vi.fn(async () => {
await cleanupReady;
await runWithOwnedSessionTranscriptWriteLock({ sessionKey }, freshTranscriptWrite);
await runWithOwnedSessionTranscriptWrite({ sessionKey }, freshTranscriptWrite);
return true;
});
const controller = createLifecycleController({ entry, runSubagentAnnounceFlow });
await withOwnedSessionTranscriptWrites(
{ sessionKey, assertOwned: () => undefined, withSessionWriteLock: withStaleWriteLock },
{ sessionKey, withTranscriptWrite: withRequesterTranscriptWrite },
async () => {
expect(controller.startSubagentAnnounceCleanupFlow(entry.runId, entry)).toBe(true);
},
@@ -592,7 +592,7 @@ describe("subagent registry lifecycle hardening", () => {
await waitForLifecycleState(() => expect(freshTranscriptWrite).toHaveBeenCalledOnce());
await waitForLifecycleState(() => expect(entry.delivery?.status).toBe("delivered"));
expect(staleWriteLock).not.toHaveBeenCalled();
expect(requesterTranscriptWrite).not.toHaveBeenCalled();
expect(runSubagentAnnounceFlow).toHaveBeenCalledOnce();
});
@@ -3836,9 +3836,9 @@ describe("requester settle wake trigger", () => {
const wakeReady = new Promise<void>((resolve) => {
releaseWake = resolve;
});
const staleWriteLock = vi.fn();
const withStaleWriteLock = async <T>(operation: () => Promise<T> | T): Promise<T> => {
staleWriteLock();
const requesterTranscriptWrite = vi.fn();
const withRequesterTranscriptWrite = async <T>(operation: () => Promise<T> | T): Promise<T> => {
requesterTranscriptWrite();
if (disposed) {
throw new Error("attempt disposed before transcript write");
}
@@ -3847,7 +3847,7 @@ describe("requester settle wake trigger", () => {
const freshTranscriptWrite = vi.fn(async () => {});
const settleWake = vi.fn(async () => {
await wakeReady;
await runWithOwnedSessionTranscriptWriteLock({ sessionKey }, freshTranscriptWrite);
await runWithOwnedSessionTranscriptWrite({ sessionKey }, freshTranscriptWrite);
return false;
});
const controller = createLifecycleController({
@@ -3856,7 +3856,7 @@ describe("requester settle wake trigger", () => {
});
await withOwnedSessionTranscriptWrites(
{ sessionKey, assertOwned: () => undefined, withSessionWriteLock: withStaleWriteLock },
{ sessionKey, withTranscriptWrite: withRequesterTranscriptWrite },
async () => {
controller.completeCleanupBookkeeping({
runId: entry.runId,
@@ -3871,7 +3871,7 @@ describe("requester settle wake trigger", () => {
releaseWake();
await waitForLifecycleState(() => expect(freshTranscriptWrite).toHaveBeenCalledOnce());
expect(staleWriteLock).not.toHaveBeenCalled();
expect(requesterTranscriptWrite).not.toHaveBeenCalled();
expect(settleWake).toHaveBeenCalledOnce();
});
+9 -9
View File
@@ -12,7 +12,7 @@ import type {
SessionEntryPatchOptions,
} from "../config/sessions/session-accessor.js";
import {
runWithOwnedSessionTranscriptWriteLock,
runWithOwnedSessionTranscriptWrite,
withOwnedSessionTranscriptWrites,
} from "../config/sessions/transcript-write-context.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -1992,9 +1992,9 @@ describe("subagent registry seam flow", () => {
const pendingWait = new Promise<Record<string, unknown>>((resolve) => {
resolveWait = resolve;
});
const staleWriteLock = vi.fn();
const withStaleWriteLock = async <T>(operation: () => Promise<T> | T): Promise<T> => {
staleWriteLock();
const requesterTranscriptWrite = vi.fn();
const withRequesterTranscriptWrite = async <T>(operation: () => Promise<T> | T): Promise<T> => {
requesterTranscriptWrite();
if (disposed) {
throw new Error("attempt disposed before transcript write");
}
@@ -2008,16 +2008,16 @@ describe("subagent registry seam flow", () => {
return {};
}
const result = await pendingWait;
await runWithOwnedSessionTranscriptWriteLock({ sessionKey }, freshCompletionWrite);
await runWithOwnedSessionTranscriptWrite({ sessionKey }, freshCompletionWrite);
return result;
});
mocks.runSubagentAnnounceFlow.mockImplementation(async () => {
await runWithOwnedSessionTranscriptWriteLock({ sessionKey }, freshTranscriptWrite);
await runWithOwnedSessionTranscriptWrite({ sessionKey }, freshTranscriptWrite);
return true;
});
await withOwnedSessionTranscriptWrites(
{ sessionKey, assertOwned: () => undefined, withSessionWriteLock: withStaleWriteLock },
{ sessionKey, withTranscriptWrite: withRequesterTranscriptWrite },
async () => {
mod.registerSubagentRun({
runId: "run-detached-requester-owner",
@@ -2039,7 +2039,7 @@ describe("subagent registry seam flow", () => {
await waitForFast(() => expect(freshTranscriptWrite).toHaveBeenCalledOnce());
expect(freshCompletionWrite).toHaveBeenCalledOnce();
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledOnce();
expect(staleWriteLock).not.toHaveBeenCalled();
expect(requesterTranscriptWrite).not.toHaveBeenCalled();
});
it("does not fall back to network recovery without an instance-bound runtime", async () => {
@@ -4755,7 +4755,7 @@ describe("subagent registry seam flow", () => {
}
});
it("does not restore session-write ownership after a successor is released", async () => {
it("does not restore a superseded lifecycle after a successor is released", async () => {
const childSessionKey = "agent:main:subagent:released-timing-owner";
mockPendingAgentWait();
mocks.loadSessionStore.mockReturnValue({
@@ -2,7 +2,7 @@
// wake delivery, and direct media fallback behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
runWithOwnedSessionTranscriptWriteLock,
runWithOwnedSessionTranscriptWrite,
withOwnedSessionTranscriptWrites,
} from "../../config/sessions/transcript-write-context.js";
import type { SessionEntry } from "../../config/sessions/types.js";
@@ -129,9 +129,9 @@ describe("scheduleMediaGenerationTaskCompletion", () => {
releaseBackground = resolve;
});
let scheduled: Promise<void> | undefined;
const staleWriteLock = vi.fn();
const withStaleWriteLock = async <T>(operation: () => Promise<T> | T): Promise<T> => {
staleWriteLock();
const requesterTranscriptWrite = vi.fn();
const withRequesterTranscriptWrite = async <T>(operation: () => Promise<T> | T): Promise<T> => {
requesterTranscriptWrite();
if (disposed) {
throw new Error("attempt disposed before transcript write");
}
@@ -139,7 +139,7 @@ describe("scheduleMediaGenerationTaskCompletion", () => {
};
const freshTranscriptWrite = vi.fn(async () => {});
const wakeTaskCompletion = vi.fn(async () => {
await runWithOwnedSessionTranscriptWriteLock({ sessionKey }, freshTranscriptWrite);
await runWithOwnedSessionTranscriptWrite({ sessionKey }, freshTranscriptWrite);
return { status: "delivered" as const };
});
const completeTaskRun = vi.fn();
@@ -159,7 +159,7 @@ describe("scheduleMediaGenerationTaskCompletion", () => {
}));
await withOwnedSessionTranscriptWrites(
{ sessionKey, assertOwned: () => undefined, withSessionWriteLock: withStaleWriteLock },
{ sessionKey, withTranscriptWrite: withRequesterTranscriptWrite },
async () => {
scheduleMediaGenerationTaskCompletion({
lifecycle,
@@ -188,7 +188,7 @@ describe("scheduleMediaGenerationTaskCompletion", () => {
}
await scheduled;
expect(staleWriteLock).not.toHaveBeenCalled();
expect(requesterTranscriptWrite).not.toHaveBeenCalled();
expect(run).toHaveBeenCalledOnce();
expect(freshTranscriptWrite).toHaveBeenCalledOnce();
expect(wakeTaskCompletion).toHaveBeenCalledOnce();
@@ -405,8 +405,7 @@ describe("runSessionsSendA2AFlow announce delivery", () => {
it("notifies the requester when delayed target delivery fails after acceptance", async () => {
vi.mocked(waitForAgentRun).mockResolvedValueOnce({
status: "timeout",
error:
"SessionWriteLockTimeoutError: session file locked (timeout 60000ms): pid=43 alive=true",
error: "target run failed after delivery acceptance",
pendingError: true,
});
@@ -435,15 +434,14 @@ describe("runSessionsSendA2AFlow announce delivery", () => {
});
const stepInput = firstMockArg(vi.mocked(runAgentStep), "agent step");
expect(stepInput.message).toContain("sessions_send delivery to");
expect(stepInput.message).toContain("SessionWriteLockTimeoutError");
expect(stepInput.message).toContain("target run failed after delivery acceptance");
expect(gatewayCalls.find((call) => call.method === "send")).toBeUndefined();
});
it("does not notify the requester for waited sends that already returned the error inline", async () => {
vi.mocked(waitForAgentRun).mockResolvedValueOnce({
status: "timeout",
error:
"SessionWriteLockTimeoutError: session file locked (timeout 60000ms): pid=43 alive=true",
error: "target run failed after delivery acceptance",
pendingError: true,
});
@@ -20,7 +20,6 @@ import {
AGENT_RUN_RESTART_ABORT_STOP_REASON,
resolveAgentRunErrorLifecycleFields,
} from "../../agents/run-termination.js";
import { isSessionWriteLockLeaseLostError } from "../../agents/session-write-lock-error.js";
import { logVerbose } from "../../globals.js";
import { emitAgentEvent } from "../../infra/agent-events.js";
import { sleepWithAbort } from "../../infra/backoff.js";
@@ -109,24 +108,6 @@ type ErrorAction =
| { kind: "retry"; liveModelSwitchError?: LiveSessionModelSwitchError }
| Extract<AgentTurnInternalResult, { kind: "final" }>;
function isSessionLeaseLoss(error: unknown): boolean {
const pending = [error];
const seen = new Set<unknown>();
for (const candidate of pending) {
if (!candidate || seen.has(candidate)) {
continue;
}
seen.add(candidate);
if (isSessionWriteLockLeaseLostError(candidate)) {
return true;
}
if (candidate instanceof Error && "cause" in candidate && candidate.cause !== undefined) {
pending.push(candidate.cause);
}
}
return false;
}
export async function handleAgentExecutionError(params: {
turn: AgentTurnParams;
error: unknown;
@@ -253,16 +234,6 @@ export async function handleAgentExecutionError(params: {
if (replyOperationAbortAction) {
return replyOperationAbortAction;
}
if (
isSessionLeaseLoss(err) &&
(await turn.confirmRestartRecoveryArmedAfterLeaseLoss?.()) === true
) {
// The replacement owns recovery only after the latest SQLite row confirms
// the active claim or its terminal marker. The old owner then exits silently.
turn.replyOperation?.abortForRestart();
takePendingLifecycleTerminal()?.emit("end", err);
return { kind: "final", payload: { text: SILENT_REPLY_TOKEN } };
}
const restartLifecycleError = resolveRestartLifecycleError(err);
if (
restartLifecycleError instanceof GatewayDrainingError ||
@@ -73,9 +73,6 @@ type ExecutePreparedReplyAgentRunInput = Pick<
checkpointBeforeAgentReply: ReturnType<
typeof createReplyRestartRecoveryClaimController
>["checkpointBeforeAgentReply"];
confirmRestartRecoveryArmedAfterLeaseLoss: ReturnType<
typeof createReplyRestartRecoveryClaimController
>["confirmRestartRecoveryArmedAfterLeaseLoss"];
getActiveIsNewSession: () => boolean;
getActiveSessionEntry: () => SessionEntry | undefined;
isHeartbeat: boolean;
@@ -114,7 +111,6 @@ export async function executePreparedReplyAgentRun(
blockStreamingEnabled,
cfg,
checkpointBeforeAgentReply: checkpointBeforeAgentReplyWithRecovery,
confirmRestartRecoveryArmedAfterLeaseLoss,
commandBody,
defaultModel,
followupRun,
@@ -381,7 +377,6 @@ export async function executePreparedReplyAgentRun(
resolvedVerboseLevel,
toolProgressDetail,
replyMediaContext,
confirmRestartRecoveryArmedAfterLeaseLoss,
isRestartRecoveryArmed,
}),
),
@@ -479,7 +474,6 @@ export function createReplyAgentRestartRecoveryController(
beginBeforeAgentReply,
checkpointBeforeAgentReply,
clear: clearRestartRecoveryDeliveryClaim,
confirmRestartRecoveryArmedAfterLeaseLoss,
isArmed: isRestartRecoveryArmed,
} = createReplyRestartRecoveryClaimController({
admissionRunId:
@@ -548,7 +542,6 @@ export function createReplyAgentRestartRecoveryController(
admitUserTurn,
beginBeforeAgentReply,
checkpointBeforeAgentReply,
confirmRestartRecoveryArmedAfterLeaseLoss,
clear: clearRestartRecoveryDeliveryClaim,
isArmed: isRestartRecoveryArmed,
};
@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { FailoverError } from "../../agents/failover-error.js";
import { AgentHarnessSessionSupersededError } from "../../agents/harness/errors.js";
import { SessionWriteLockStaleError } from "../../agents/session-write-lock-error.js";
import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
import { getReplyPayloadMetadata } from "../reply-payload.js";
import type { TemplateContext } from "../templating.js";
@@ -382,55 +381,6 @@ describe("executeAgentTurn: terminal failures", () => {
).toBe(true);
});
it("hands a confirmed restart lease loss to the replacement owner", async () => {
const agentEvents = await import("../../infra/agent-events.js");
const emitAgentEvent = vi.mocked(agentEvents.emitAgentEvent);
const { replyOperation, failMock } = createMockReplyOperation();
const abortForRestart = vi.spyOn(replyOperation, "abortForRestart");
abortForRestart.mockImplementationOnce(() => {
Object.defineProperty(replyOperation, "result", {
value: { kind: "aborted", code: "aborted_for_restart" } as const,
configurable: true,
});
return true;
});
state.runEmbeddedAgentMock.mockRejectedValueOnce(
new Error("embedded runner failed", {
cause: new SessionWriteLockStaleError({
lockPath: "sqlite:session-write:agent:main:main",
owner: "replacement gateway",
staleReasons: ["lease-lost"],
}),
}),
);
const confirmRestartRecoveryArmedAfterLeaseLoss = vi.fn(async () => true);
const executeAgentTurn = await getExecuteAgentTurnForTest();
const result = await executeAgentTurn({
...createMinimalRunAgentTurnParams({ replyOperation }),
confirmRestartRecoveryArmedAfterLeaseLoss,
isRestartRecoveryArmed: () => false,
});
expect(result).toEqual({ kind: "final", payload: { text: SILENT_REPLY_TOKEN } });
expect(confirmRestartRecoveryArmedAfterLeaseLoss).toHaveBeenCalledOnce();
expect(abortForRestart).toHaveBeenCalledOnce();
expect(failMock).not.toHaveBeenCalled();
expect(
emitAgentEvent.mock.calls.filter(
([event]) =>
event.stream === "lifecycle" &&
event.data.phase === "end" &&
event.data.stopReason === "restart",
),
).toHaveLength(1);
expect(
emitAgentEvent.mock.calls.some(
([event]) => event.stream === "lifecycle" && event.data.phase === "error",
),
).toBe(false);
});
it("preserves restart ownership when an aborted embedded runner resolves normally", async () => {
const agentEvents = await import("../../infra/agent-events.js");
const emitAgentEvent = vi.mocked(agentEvents.emitAgentEvent);
@@ -105,7 +105,6 @@ export type AgentTurnParams = {
toolProgressDetail?: "explain" | "raw";
replyMediaContext?: ReplyMediaContext;
onCompactionNoticePayload?: (payload: ReplyPayload) => Promise<void> | void;
confirmRestartRecoveryArmedAfterLeaseLoss?: () => Promise<boolean>;
isRestartRecoveryArmed?: () => boolean;
};
-2
View File
@@ -453,7 +453,6 @@ export async function runReplyAgent(
beginBeforeAgentReply,
checkpointBeforeAgentReply,
clear: clearRestartRecoveryDeliveryClaim,
confirmRestartRecoveryArmedAfterLeaseLoss,
isArmed: isRestartRecoveryArmed,
} = createReplyAgentRestartRecoveryController({
activeSessionStore,
@@ -521,7 +520,6 @@ export async function runReplyAgent(
blockStreamingEnabled,
cfg,
checkpointBeforeAgentReply,
confirmRestartRecoveryArmedAfterLeaseLoss,
commandBody,
defaultModel,
followupRun,
@@ -5,7 +5,6 @@ import { join } from "node:path";
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { SessionWriteLockStaleError } from "../../agents/session-write-lock-error.js";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import {
@@ -23,7 +22,6 @@ import {
import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js";
import { createTestUserTurnTranscriptTarget } from "../../sessions/user-turn-transcript.test-support.js";
import type { TemplateContext } from "../templating.js";
import { SILENT_REPLY_TOKEN } from "../tokens.js";
import {
GENERIC_EXTERNAL_RUN_FAILURE_TEXT,
HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT,
@@ -2473,73 +2471,6 @@ describe("runReplyAgent pending final delivery capture", () => {
}
});
it("preserves an adopted restart claim when the replacement takes the SQLite lease", async () => {
const { sessionEntry, sessionStore, storePath } = await makeSessionFixture({
abortedLastRun: false,
restartRecoveryDeliveryRequestFingerprint: "request-fingerprint",
restartRecoveryDeliveryRunId: "msg",
restartRecoveryDeliverySourceRunId: "control-ui-run",
status: "running",
});
const replyOperation = createReplyOperation({
sessionKey: "main",
sessionId: "session",
resetTriggered: false,
});
replyOperation.setPhase("running");
state.runEmbeddedAgentMock.mockImplementationOnce(async () => {
const current = await readStoredMainSession(storePath);
expect(current.restartRecoveryDeliveryRunId).toBe("msg");
await replaceSessionEntry(
{ storePath, sessionKey: "main" },
{
...current,
abortedLastRun: true,
status: "killed",
updatedAt: Date.now(),
},
);
throw new SessionWriteLockStaleError({
lockPath: "sqlite:session-write:agent:main:main",
owner: "replacement gateway",
staleReasons: ["lease-lost"],
});
});
try {
const { run } = createMinimalRun({
replyOperation,
sessionCtx: {
Provider: "webchat",
OriginatingChannel: "webchat",
},
runOverrides: { agentId: "main", messageProvider: "webchat" },
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
});
await expect(run()).resolves.toEqual({ text: SILENT_REPLY_TOKEN });
expect(replyOperation.result).toEqual({
kind: "aborted",
code: "aborted_for_restart",
});
expect(await readStoredMainSession(storePath)).toMatchObject({
abortedLastRun: true,
restartRecoveryDeliveryRunId: "msg",
restartRecoveryDeliverySourceRunId: "control-ui-run",
status: "killed",
});
expect(
(await readStoredMainSession(storePath)).restartRecoveryTerminalRunIds,
).toBeUndefined();
} finally {
replyOperation.complete();
}
});
it("fires onAdopted after restart recovery delivery context persist completes", async () => {
const { sessionEntry, sessionStore, storePath } = await makeSessionFixture();
const expectedSourceTurnId = requireBuiltChannelSourceTurnId({
@@ -1,5 +1,4 @@
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import {
@@ -11,7 +10,6 @@ import {
replaceSessionEntry,
updateSessionEntry,
} from "../../config/sessions/session-accessor.js";
import { resolveSqliteReadScope } from "../../config/sessions/session-accessor.sqlite-scope.js";
import type { InternalSessionEntry, SessionEntry } from "../../config/sessions/types.js";
import { getAgentEventLifecycleGeneration } from "../../infra/agent-events.js";
import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js";
@@ -19,7 +17,6 @@ import type {
UserTurnTranscriptRecorder,
UserTurnTranscriptTarget,
} from "../../sessions/user-turn-transcript.types.js";
import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
import { createReplyRestartRecoveryClaimController } from "./restart-recovery-claim.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
@@ -45,24 +42,6 @@ function createTestAdmission(params: {
};
}
function replaceSessionEntryFromIndependentConnection(params: {
entry: SessionEntry;
sessionKey: string;
storePath: string;
}): void {
const database = openOpenClawAgentDatabase(
resolveSqliteReadScope({ storePath: params.storePath, sessionKey: params.sessionKey }),
);
const external = new DatabaseSync(database.path);
try {
external
.prepare("UPDATE session_nodes SET entry_json = ?, updated_at = ? WHERE session_key = ?")
.run(JSON.stringify(params.entry), params.entry.updatedAt, params.sessionKey);
} finally {
external.close();
}
}
describe("createReplyRestartRecoveryClaimController", () => {
it.each([
{ receiptState: undefined, expectedStatus: "done" },
@@ -458,120 +437,4 @@ describe("createReplyRestartRecoveryClaimController", () => {
status: "done",
});
});
it("confirms an active replacement claim after SQLite lease loss", async () => {
const root = tempDirs.make("openclaw-reply-restart-handoff-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:main";
const sessionId = "session";
let entry: SessionEntry = {
abortedLastRun: false,
restartRecoveryDeliveryRunId: "old-run",
restartRecoveryDeliverySourceRunId: "source-run",
sessionId,
status: "running",
updatedAt: Date.now(),
};
await replaceSessionEntry({ storePath, sessionKey }, entry);
const controller = createReplyRestartRecoveryClaimController({
admissionRunId: "old-run",
getEntry: () => entry,
getSessionId: () => sessionId,
isRestartAbort: () => false,
resolveDeliveryContext: () => undefined,
sessionKey,
setEntry: (next) => {
entry = next;
},
storePath,
});
await expect(controller.admitUserTurn()).resolves.toBe("admitted");
replaceSessionEntryFromIndependentConnection({
entry: {
...entry,
abortedLastRun: true,
status: "killed",
updatedAt: Date.now() + 1,
},
sessionKey,
storePath,
});
expect(entry.abortedLastRun).toBe(false);
await expect(controller.confirmRestartRecoveryArmedAfterLeaseLoss()).resolves.toBe(true);
expect(entry).toMatchObject({
abortedLastRun: true,
restartRecoveryDeliveryRunId: "old-run",
restartRecoveryDeliverySourceRunId: "source-run",
status: "killed",
});
await controller.clear();
expect(loadSessionEntry({ storePath, sessionKey, readConsistency: "latest" })).toMatchObject({
abortedLastRun: true,
restartRecoveryDeliveryRunId: "old-run",
restartRecoveryDeliverySourceRunId: "source-run",
status: "killed",
});
});
it("confirms a completed replacement handoff by its terminal source marker", async () => {
const root = tempDirs.make("openclaw-reply-completed-handoff-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:main";
const sessionId = "session";
let entry: SessionEntry = {
abortedLastRun: false,
restartRecoveryDeliveryRunId: "old-run",
restartRecoveryDeliverySourceRunId: "source-run",
sessionId,
status: "running",
updatedAt: Date.now(),
};
await replaceSessionEntry({ storePath, sessionKey }, entry);
const controller = createReplyRestartRecoveryClaimController({
admissionRunId: "old-run",
getEntry: () => entry,
getSessionId: () => sessionId,
isRestartAbort: () => false,
resolveDeliveryContext: () => undefined,
sessionKey,
setEntry: (next) => {
entry = next;
},
storePath,
});
await expect(controller.admitUserTurn()).resolves.toBe("admitted");
replaceSessionEntryFromIndependentConnection({
entry: {
...entry,
abortedLastRun: false,
restartRecoveryDeliveryRunId: undefined,
restartRecoveryDeliverySourceRunId: undefined,
restartRecoveryTerminalRunIds: ["source-run"],
status: "done",
updatedAt: Date.now() + 1,
},
sessionKey,
storePath,
});
expect(entry.restartRecoveryTerminalRunIds).toBeUndefined();
await expect(controller.confirmRestartRecoveryArmedAfterLeaseLoss()).resolves.toBe(true);
expect(entry).toMatchObject({
abortedLastRun: false,
restartRecoveryTerminalRunIds: ["source-run"],
status: "done",
});
expect(entry.restartRecoveryDeliveryRunId).toBeUndefined();
expect(entry.restartRecoveryDeliverySourceRunId).toBeUndefined();
await controller.clear();
const persisted = loadSessionEntry({ storePath, sessionKey, readConsistency: "latest" });
expect(persisted?.restartRecoveryTerminalRunIds).toEqual(["source-run"]);
expect(persisted?.restartRecoveryDeliveryRunId).toBeUndefined();
expect(persisted?.restartRecoveryDeliverySourceRunId).toBeUndefined();
});
});
+1 -39
View File
@@ -34,7 +34,6 @@ type ReplyRestartRecoveryClaimController = {
};
}) => Promise<void>;
clear: () => Promise<void>;
confirmRestartRecoveryArmedAfterLeaseLoss: () => Promise<boolean>;
isArmed: () => boolean;
};
@@ -130,7 +129,6 @@ export function createReplyRestartRecoveryClaimController(params: {
let recoveryRunId: string = randomUUID();
let recoverySourceRunId: string | undefined;
let tracked = false;
let leaseLossRestartHandoffConfirmed = false;
const persistAdmissionPatch = async (options: {
entry: SessionEntry;
@@ -438,43 +436,8 @@ export function createReplyRestartRecoveryClaimController(params: {
return true;
};
const confirmRestartRecoveryArmedAfterLeaseLoss = async (): Promise<boolean> => {
if (!tracked || !params.sessionKey || !params.storePath || !recoverySourceRunId) {
return false;
}
// Lease loss means the replacement may have advanced the authoritative row
// past this process's cache. This cold error path performs one latest read.
const persisted = loadSessionEntry({
sessionKey: params.sessionKey,
storePath: params.storePath,
clone: false,
hydrateSkillPromptRefs: false,
readConsistency: "latest",
});
if (!persisted || persisted.sessionId !== params.getSessionId()) {
return false;
}
params.setEntry(persisted);
const activeHandoff =
persisted.abortedLastRun === true &&
normalizeOptionalString(persisted.restartRecoveryDeliveryRunId) === recoveryRunId &&
hasRestartRecoverySourceClaim(persisted, recoverySourceRunId);
// The replacement may finish and clear the active claim before the old
// owner observes lease loss. Its terminal source marker proves completion.
const completedHandoff = hasRestartRecoveryTerminalRun(persisted, recoverySourceRunId);
const armed = activeHandoff || completedHandoff;
leaseLossRestartHandoffConfirmed ||= armed;
return armed;
};
const clear = async (): Promise<void> => {
if (
!tracked ||
!params.sessionKey ||
!params.storePath ||
params.isRestartAbort() ||
leaseLossRestartHandoffConfirmed
) {
if (!tracked || !params.sessionKey || !params.storePath || params.isRestartAbort()) {
return;
}
const persisted = await updateSessionEntry(
@@ -568,7 +531,6 @@ export function createReplyRestartRecoveryClaimController(params: {
beginBeforeAgentReply,
checkpointBeforeAgentReply,
clear,
confirmRestartRecoveryArmedAfterLeaseLoss,
isArmed,
};
}
@@ -55,27 +55,6 @@ vi.mock("../../plugin-sdk/browser-maintenance.js", () => ({
closeTrackedBrowserTabsForSessions: sessionCleanupMocks.closeTrackedBrowserTabsForSessions,
}));
vi.mock("../../agents/session-write-lock.js", async () => {
const actual = await vi.importActual<typeof import("../../agents/session-write-lock.js")>(
"../../agents/session-write-lock.js",
);
return {
...actual,
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => {} })),
resolveSessionLockMaxHoldFromTimeout: vi.fn(
({
timeoutMs,
graceMs = 2 * 60 * 1000,
minMs = 5 * 60 * 1000,
}: {
timeoutMs: number;
graceMs?: number;
minMs?: number;
}) => Math.max(minMs, timeoutMs + graceMs),
),
};
});
const suiteTempDirs = createSuiteTempRootTracker({ prefix: "openclaw-session-hooks-" });
async function createStorePath(prefix: string): Promise<string> {
-22
View File
@@ -111,28 +111,6 @@ vi.mock("../../infra/channel-summary.js", () => ({
buildChannelSummary: channelSummaryMocks.buildChannelSummary,
}));
// Perf: session-store locks are exercised elsewhere; most session tests don't need FS lock files.
vi.mock("../../agents/session-write-lock.js", async () => {
const actual = await vi.importActual<typeof import("../../agents/session-write-lock.js")>(
"../../agents/session-write-lock.js",
);
return {
...actual,
acquireSessionWriteLock: vi.fn(async () => ({ release: async () => {} })),
resolveSessionLockMaxHoldFromTimeout: vi.fn(
({
timeoutMs,
graceMs = 2 * 60 * 1000,
minMs = 5 * 60 * 1000,
}: {
timeoutMs: number;
graceMs?: number;
minMs?: number;
}) => Math.max(minMs, timeoutMs + graceMs),
),
};
});
vi.mock("../../agents/prepared-model-catalog.js", () => ({
loadProviderScopedThinkingCatalog: vi.fn(async () => []),
loadPreparedModelCatalog: vi.fn(async () => [

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