From ce53f7e82e25e49f6c78736bf14e2861ee774135 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 9 Aug 2026 15:30:48 -0700 Subject: [PATCH] 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 --- config/env-var-count-budget.txt | 2 +- .../.generated/plugin-sdk-api-baseline.sha256 | 88 +- docs/cli/doctor.md | 1 - docs/concepts/agent-loop.md | 6 +- docs/plugins/sdk-subpaths.md | 1 + .../session-management-compaction.md | 8 +- .../src/suite-runtime-agent-session.test.ts | 46 - .../qa-lab/src/suite-runtime-agent-session.ts | 52 +- scripts/plugin-sdk-surface-report.mts | 6 +- .../command/attempt-execution.cli.test.ts | 45 - src/agents/command/attempt-execution.ts | 40 +- src/agents/embedded-agent-helpers/errors.ts | 2 +- .../compact.hooks.harness.ts | 35 +- .../compact.hooks.test.ts | 38 - .../compaction-session-execution.ts | 23 - .../google-prompt-cache.test.ts | 6 +- .../google-prompt-cache.ts | 5 +- .../run/attempt-abort.test.ts | 98 +- .../run/attempt-abort.ts | 40 +- .../run/attempt-after-turn.ts | 15 +- .../run/attempt-before-agent-run.test.ts | 4 +- .../run/attempt-before-agent-run.ts | 6 +- .../run/attempt-execution-phase.test.ts | 3 +- .../run/attempt-execution-phase.ts | 1 - .../run/attempt-execution-settle.test.ts | 3 +- .../run/attempt-execution-settle.ts | 6 +- .../run/attempt-execution-types.ts | 11 +- .../run/attempt-phase-lifecycle.test.ts | 21 +- .../run/attempt-prompt-dispatch.ts | 3 +- .../run/attempt-prompt-error.test.ts | 13 +- .../run/attempt-prompt-error.ts | 15 +- .../attempt-prompt-execution-prepare.test.ts | 38 +- .../run/attempt-prompt-execution-prepare.ts | 24 - .../run/attempt-prompt-phase.test.ts | 4 +- .../run/attempt-prompt-phase.ts | 21 +- .../run/attempt-session-cleanup.test.ts | 48 +- .../run/attempt-session-cleanup.ts | 70 +- .../run/attempt-session-lock-prepare.test.ts | 182 ---- .../run/attempt-session-lock-prepare.ts | 125 --- .../run/attempt-session-manager-prepare.ts | 13 +- .../attempt-session-runtime-prepare.test.ts | 4 +- .../run/attempt-session-runtime-prepare.ts | 10 +- .../run/attempt-session.test.ts | 6 +- .../run/attempt-session.ts | 8 +- .../run/attempt-stream-finalize.test.ts | 72 +- .../run/attempt-stream-finalize.ts | 33 +- .../run/attempt-stream-prepare.test.ts | 13 +- .../run/attempt-stream-runtime-prepare.ts | 4 - .../run/attempt-stream-settle.test.ts | 3 +- .../run/attempt-stream-settle.ts | 11 +- .../run/attempt-stream.ts | 7 - .../run/attempt-timeout-prepare.test.ts | 21 +- ...tempt-transcript-lifecycle-prepare.test.ts | 38 + .../attempt-transcript-lifecycle-prepare.ts | 84 ++ .../run/attempt-transcript-lifecycle.test.ts | 58 ++ .../run/attempt-transcript-lifecycle.ts | 210 ++++ .../run/attempt.abort-race.test.ts | 40 +- .../run/attempt.run-decisions.test.ts | 118 --- .../run/attempt.run-decisions.ts | 21 - .../run/attempt.session-lock.teardown.test.ts | 186 ---- .../run/attempt.session-lock.test.ts | 936 ------------------ .../run/attempt.session-lock.ts | 654 ------------ ...mpt.spawn-workspace.context-engine.test.ts | 5 +- .../attempt.spawn-workspace.test-support.ts | 24 - .../run/attempt.subscription-cleanup.test.ts | 89 +- .../run/attempt.subscription-cleanup.ts | 69 +- .../attempt.tool-search-catalog-abort.test.ts | 45 - .../embedded-agent-runner/run/attempt.ts | 38 +- .../run/lane-controller.writer-claim.test.ts | 3 +- src/agents/embedded-agent-runner/runs.ts | 4 +- src/agents/failover-error.test.ts | 178 +--- src/agents/failover-error.ts | 96 +- .../harness/context-engine-lifecycle.ts | 8 +- src/agents/model-fallback.test.ts | 182 +--- src/agents/session-write-lock-error.ts | 55 - src/agents/session-write-lock.test-support.ts | 28 - src/agents/session-write-lock.test.ts | 287 ------ src/agents/session-write-lock.ts | 403 -------- src/agents/sessions/agent-session-base.ts | 20 +- .../sessions/agent-session-compaction.ts | 11 +- src/agents/sessions/agent-session-types.ts | 6 +- src/agents/sessions/sdk.test.ts | 38 +- src/agents/sessions/sdk.ts | 18 +- ...subagent-announce-delivery.test-support.ts | 12 +- src/agents/subagent-announce-delivery.test.ts | 110 +- src/agents/subagent-announce-delivery.ts | 41 +- .../subagent-registry-lifecycle.test.ts | 26 +- src/agents/subagent-registry.test.ts | 18 +- .../media-generate-background-shared.test.ts | 14 +- .../tools/sessions-send-tool.a2a.test.ts | 8 +- .../reply/agent-runner-error-handler.ts | 29 - src/auto-reply/reply/agent-runner-execute.ts | 7 - ...runner-execution-terminal-failures.test.ts | 50 - .../reply/agent-runner-execution.types.ts | 1 - src/auto-reply/reply/agent-runner-run.ts | 2 - .../agent-runner.runreplyagent.e2e.test.ts | 69 -- .../reply/restart-recovery-claim.test.ts | 137 --- .../reply/restart-recovery-claim.ts | 40 +- .../reply/session-hooks-context.test.ts | 21 - src/auto-reply/reply/session.test.ts | 22 - src/cli/gateway-cli/run-loop.ts | 4 +- src/commands/doctor-session-lock-files.ts | 136 --- src/commands/doctor-session-locks.test.ts | 387 -------- src/commands/doctor-session-locks.ts | 162 --- src/commands/doctor.fast-path-mocks.ts | 4 - src/config/sessions/session-accessor.entry.ts | 16 +- .../sessions/session-accessor.sqlite-entry.ts | 11 +- .../session-accessor.sqlite-parent-session.ts | 2 +- ...on-accessor.sqlite-transcript-sequences.ts | 2 +- ...ession-accessor.sqlite-transcript-write.ts | 11 +- src/config/sessions/session-accessor.test.ts | 50 +- .../session-accessor.transcript-turn.ts | 20 +- .../sessions/session-accessor.transcript.ts | 38 +- .../sessions/session-history-eviction.ts | 2 +- .../transcript-append.test-support.ts | 30 +- .../sessions/transcript-write-context.ts | 163 +-- src/config/sessions/transcript.test.ts | 40 +- src/flows/doctor-core-checks.test.ts | 24 - src/flows/doctor-core-checks.ts | 34 - ...octor-health-contribution-runners.state.ts | 9 - .../doctor-health-contributions-initial.ts | 7 - .../chat.directive-tags.test.ts | 3 +- src/infra/gateway-process-argv.test.ts | 20 +- src/infra/gateway-process-argv.ts | 21 - src/infra/gateway-processes.test.ts | 86 +- src/infra/gateway-processes.ts | 2 +- src/infra/outbound/deliver.test.ts | 2 +- src/infra/session-lock-file-inspection.ts | 144 --- .../session-write-lock-runtime.test.ts | 466 +-------- src/plugin-sdk/session-write-lock-runtime.ts | 293 +----- src/state/openclaw-state-lease-owner.ts | 78 -- src/state/openclaw-state-lease.test.ts | 86 -- src/state/openclaw-state-lease.ts | 279 ++---- src/test-utils/session-state-cleanup.test.ts | 8 - src/test-utils/session-state-cleanup.ts | 8 - .../embedded-agent-transcript.runtime.ts | 8 +- src/worker/embedded-agent.runtime.ts | 4 +- test/setup-openclaw-runtime.ts | 26 +- test/vitest/vitest.unit-fast-paths.mjs | 1 + 139 files changed, 1139 insertions(+), 7467 deletions(-) delete mode 100644 src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.test.ts delete mode 100644 src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.ts create mode 100644 src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle-prepare.test.ts create mode 100644 src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle-prepare.ts create mode 100644 src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle.test.ts create mode 100644 src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle.ts delete mode 100644 src/agents/embedded-agent-runner/run/attempt.run-decisions.test.ts delete mode 100644 src/agents/embedded-agent-runner/run/attempt.session-lock.teardown.test.ts delete mode 100644 src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts delete mode 100644 src/agents/embedded-agent-runner/run/attempt.session-lock.ts delete mode 100644 src/agents/session-write-lock-error.ts delete mode 100644 src/agents/session-write-lock.test-support.ts delete mode 100644 src/agents/session-write-lock.test.ts delete mode 100644 src/agents/session-write-lock.ts delete mode 100644 src/commands/doctor-session-lock-files.ts delete mode 100644 src/commands/doctor-session-locks.test.ts delete mode 100644 src/commands/doctor-session-locks.ts delete mode 100644 src/infra/session-lock-file-inspection.ts delete mode 100644 src/state/openclaw-state-lease-owner.ts diff --git a/config/env-var-count-budget.txt b/config/env-var-count-budget.txt index 27b4b6fdc499..c43937ae4ff4 100644 --- a/config/env-var-count-budget.txt +++ b/config/env-var-count-budget.txt @@ -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 diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 69de187426bf..a7e40a72f72e 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -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 diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 83abef41acc1..95a387785f51 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -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. diff --git a/docs/concepts/agent-loop.md b/docs/concepts/agent-loop.md index 79670d5f19ef..e1fccbcc012b 100644 --- a/docs/concepts/agent-loop.md +++ b/docs/concepts/agent-loop.md @@ -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 diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index c7b7b97928a4..4e29beb721d9 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -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 | diff --git a/docs/reference/session-management-compaction.md b/docs/reference/session-management-compaction.md index c41242244c81..c3e5a095a9ba 100644 --- a/docs/reference/session-management-compaction.md +++ b/docs/reference/session-management-compaction.md @@ -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 diff --git a/extensions/qa-lab/src/suite-runtime-agent-session.test.ts b/extensions/qa-lab/src/suite-runtime-agent-session.test.ts index 174d1bc38db7..04d7740541ac 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-session.test.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-session.test.ts @@ -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: [ diff --git a/extensions/qa-lab/src/suite-runtime-agent-session.ts b/extensions/qa-lab/src/suite-runtime-agent-session.ts index 5501e2c51cbc..dcb1b3919219 100644 --- a/extensions/qa-lab/src/suite-runtime-agent-session.ts +++ b/extensions/qa-lab/src/suite-runtime-agent-session.ts @@ -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( - env: QaGatewayCallEnv, - method: string, - params: Record, - 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(); 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 ?? []; } diff --git a/scripts/plugin-sdk-surface-report.mts b/scripts/plugin-sdk-surface-report.mts index 13b557a5978e..2a00961abe69 100644 --- a/scripts/plugin-sdk-surface-report.mts +++ b/scripts/plugin-sdk-surface-report.mts @@ -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( diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts index 4cd444e6ec93..4e1a7908cced 100644 --- a/src/agents/command/attempt-execution.cli.test.ts +++ b/src/agents/command/attempt-execution.cli.test.ts @@ -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( - "../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"); diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts index d7d4c5ed47ed..1009fb011a2a 100644 --- a/src/agents/command/attempt-execution.ts +++ b/src/agents/command/attempt-execution.ts @@ -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: { diff --git a/src/agents/embedded-agent-helpers/errors.ts b/src/agents/embedded-agent-helpers/errors.ts index 717eb532a2d9..334d9c352949 100644 --- a/src/agents/embedded-agent-helpers/errors.ts +++ b/src/agents/embedded-agent-helpers/errors.ts @@ -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; } diff --git a/src/agents/embedded-agent-runner/compact.hooks.harness.ts b/src/agents/embedded-agent-runner/compact.hooks.harness.ts index 50d358e59071..6046a7edf258 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.harness.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.harness.ts @@ -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. */ diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index 98f489ef74d6..d0f444235e88 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -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 (run: () => Promise | T): Promise => { - 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", diff --git a/src/agents/embedded-agent-runner/compaction-session-execution.ts b/src/agents/embedded-agent-runner/compaction-session-execution.ts index 5ba293de27fc..13d685b3981c 100644 --- a/src/agents/embedded-agent-runner/compaction-session-execution.ts +++ b/src/agents/embedded-agent-runner/compaction-session-execution.ts @@ -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({ diff --git a/src/agents/embedded-agent-runner/google-prompt-cache.test.ts b/src/agents/embedded-agent-runner/google-prompt-cache.test.ts index 61f60eb6a435..45f4090c01aa 100644 --- a/src/agents/embedded-agent-runner/google-prompt-cache.test.ts +++ b/src/agents/embedded-agent-runner/google-prompt-cache.test.ts @@ -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; diff --git a/src/agents/embedded-agent-runner/google-prompt-cache.ts b/src/agents/embedded-agent-runner/google-prompt-cache.ts index 7a96aa5df8bc..fd93bd8189e4 100644 --- a/src/agents/embedded-agent-runner/google-prompt-cache.ts +++ b/src/agents/embedded-agent-runner/google-prompt-cache.ts @@ -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 diff --git a/src/agents/embedded-agent-runner/run/attempt-abort.test.ts b/src/agents/embedded-agent-runner/run/attempt-abort.test.ts index 6c3c1121e3a4..1b8d8346d13f 100644 --- a/src/agents/embedded-agent-runner/run/attempt-abort.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-abort.test.ts @@ -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", - ); }); }); diff --git a/src/agents/embedded-agent-runner/run/attempt-abort.ts b/src/agents/embedded-agent-runner/run/attempt-abort.ts index 9e8ba0086efa..eba789b80f35 100644 --- a/src/agents/embedded-agent-runner/run/attempt-abort.ts +++ b/src/agents/embedded-agent-runner/run/attempt-abort.ts @@ -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; 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; - 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)}`, - ); - }); -} diff --git a/src/agents/embedded-agent-runner/run/attempt-after-turn.ts b/src/agents/embedded-agent-runner/run/attempt-after-turn.ts index c4336140e31a..f613b8d89c15 100644 --- a/src/agents/embedded-agent-runner/run/attempt-after-turn.ts +++ b/src/agents/embedded-agent-runner/run/attempt-after-turn.ts @@ -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; type AnthropicPayloadLogger = ReturnType; type HookRunner = ReturnType; -type AttemptSessionLockController = Awaited< - ReturnType ->; type PromptCacheInfo = ReturnType; -type WithOwnedSessionWriteLock = (operation: () => Promise | T) => Promise; +type WithOwnedTranscriptWrite = (operation: () => Promise | T) => Promise; 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({ diff --git a/src/agents/embedded-agent-runner/run/attempt-before-agent-run.test.ts b/src/agents/embedded-agent-runner/run/attempt-before-agent-run.test.ts index c76639be3dd1..db97773ae53b 100644 --- a/src/agents/embedded-agent-runner/run/attempt-before-agent-run.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-before-agent-run.test.ts @@ -40,7 +40,7 @@ function createInput(hooks: PluginHookRegistration[] = []) { agent: { state }, }; const lock = { acquisitions: 0 }; - const withOwnedSessionWriteLock = async (operation: () => Promise | T): Promise => { + const withOwnedTranscriptWrite = async (operation: () => Promise | T): Promise => { 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, diff --git a/src/agents/embedded-agent-runner/run/attempt-before-agent-run.ts b/src/agents/embedded-agent-runner/run/attempt-before-agent-run.ts index 9790b14a194f..9e608c764afa 100644 --- a/src/agents/embedded-agent-runner/run/attempt-before-agent-run.ts +++ b/src/agents/embedded-agent-runner/run/attempt-before-agent-run.ts @@ -14,7 +14,7 @@ type HookRunner = NonNullable>; type BeforeAgentRunHookRunner = Pick; type HookContext = Parameters[1]; type AttemptSessionManager = Parameters[0]; -type WithOwnedSessionWriteLock = (operation: () => Promise | T) => Promise; +type WithOwnedTranscriptWrite = (operation: () => Promise | T) => Promise; type BeforeAgentRunSession = { messages: AgentMessage[]; @@ -38,7 +38,7 @@ export async function runEmbeddedAttemptBeforeAgentRun(input: { modelPrompt: string; sessionManager: AttemptSessionManager; systemPrompt: string; - withOwnedSessionWriteLock: WithOwnedSessionWriteLock; + withOwnedTranscriptWrite: WithOwnedTranscriptWrite; }): Promise { 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[0], ); diff --git a/src/agents/embedded-agent-runner/run/attempt-execution-phase.test.ts b/src/agents/embedded-agent-runner/run/attempt-execution-phase.test.ts index af2d3b7387d7..ade5994cd57a 100644 --- a/src/agents/embedded-agent-runner/run/attempt-execution-phase.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-execution-phase.test.ts @@ -89,8 +89,7 @@ function createFixture() { sessionLock: { compactionTimeoutMs: 1_000, ownedTranscriptWriteContext: {}, - sessionLockController: {}, - withOwnedSessionWriteLock: vi.fn(), + withOwnedTranscriptWrite: vi.fn(), }, setup: { effectiveFsWorkspaceOnly: false, diff --git a/src/agents/embedded-agent-runner/run/attempt-execution-phase.ts b/src/agents/embedded-agent-runner/run/attempt-execution-phase.ts index bb196af1fec6..0b760f4508f6 100644 --- a/src/agents/embedded-agent-runner/run/attempt-execution-phase.ts +++ b/src/agents/embedded-agent-runner/run/attempt-execution-phase.ts @@ -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, diff --git a/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts b/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts index fc269873fc28..9890c84cbb7a 100644 --- a/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts @@ -162,8 +162,7 @@ function createFixture() { }, }, sessionLock: { - sessionLockController: {}, - withOwnedSessionWriteLock: vi.fn(), + withOwnedTranscriptWrite: vi.fn(), }, setup: { effectiveFsWorkspaceOnly: false, diff --git a/src/agents/embedded-agent-runner/run/attempt-execution-settle.ts b/src/agents/embedded-agent-runner/run/attempt-execution-settle.ts index be6aa938b82f..de63b2f59105 100644 --- a/src/agents/embedded-agent-runner/run/attempt-execution-settle.ts +++ b/src/agents/embedded-agent-runner/run/attempt-execution-settle.ts @@ -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, diff --git a/src/agents/embedded-agent-runner/run/attempt-execution-types.ts b/src/agents/embedded-agent-runner/run/attempt-execution-types.ts index 463fc2a64680..989d9b1504ca 100644 --- a/src/agents/embedded-agent-runner/run/attempt-execution-types.ts +++ b/src/agents/embedded-agent-runner/run/attempt-execution-types.ts @@ -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 unknown> = Awaited>; type PreparedSetup = Prepared; -type PreparedSessionLock = Prepared; +type PreparedTranscriptLifecycle = Prepared; type StreamRuntimeInput = Parameters[0]; type AttemptContextEngine = NonNullable; @@ -49,11 +49,8 @@ export type EmbeddedAttemptExecutionPhaseInput = { toolCatalog: ReturnType; }; sessionLock: Pick< - PreparedSessionLock, - | "compactionTimeoutMs" - | "ownedTranscriptWriteContext" - | "sessionLockController" - | "withOwnedSessionWriteLock" + PreparedTranscriptLifecycle, + "compactionTimeoutMs" | "ownedTranscriptWriteContext" | "withOwnedTranscriptWrite" >; setup: Pick< PreparedSetup, diff --git a/src/agents/embedded-agent-runner/run/attempt-phase-lifecycle.test.ts b/src/agents/embedded-agent-runner/run/attempt-phase-lifecycle.test.ts index c28e624bc78f..84994b6e396f 100644 --- a/src/agents/embedded-agent-runner/run/attempt-phase-lifecycle.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-phase-lifecycle.test.ts @@ -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, diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-dispatch.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-dispatch.ts index c7bb04dae937..20809d306794 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-dispatch.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-dispatch.ts @@ -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, }); diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-error.test.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-error.test.ts index 08e8166f489c..3418e7a06922 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-error.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-error.test.ts @@ -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 = {}): 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, diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-error.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-error.ts index fa861232c63c..227ba6751075 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-error.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-error.ts @@ -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; -type PromptErrorSessionLockController = Pick< - EmbeddedAttemptSessionLockController, - "releaseHeldLockForAbort" ->; -type WithOwnedSessionWriteLock = (operation: () => Promise | T) => Promise; +type WithOwnedTranscriptWrite = (operation: () => Promise | T) => Promise; 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 | 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 {}; diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.test.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.test.ts index 530f377181db..ba59baf59906 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.test.ts @@ -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) => 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 = {}): 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; - releaseForPrompt: () => Promise; - } - | 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 }), ); diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.ts index 0e2669679958..5c59c2b69419 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-execution-prepare.ts @@ -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 { 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()); diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-phase.test.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-phase.test.ts index 62894e6f2de5..0acbade6e05a 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-phase.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-phase.test.ts @@ -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 (operation: () => Promise | T) => await operation(), + withOwnedTranscriptWrite: async (operation: () => Promise | T) => await operation(), getCompactionReserveTokens: () => 77, assembly: { hookRunner: null, diff --git a/src/agents/embedded-agent-runner/run/attempt-prompt-phase.ts b/src/agents/embedded-agent-runner/run/attempt-prompt-phase.ts index 2cd8f1146086..68051da749a6 100644 --- a/src/agents/embedded-agent-runner/run/attempt-prompt-phase.ts +++ b/src/agents/embedded-agent-runner/run/attempt-prompt-phase.ts @@ -41,7 +41,7 @@ type PromptContextPhaseInput = Omit< PromptContextInput, "attempt" | "messages" | "prompt" | "replaceSessionMessages" >; -type PromptExecutionPhaseInput = Omit; +type PromptExecutionPhaseInput = PromptDispatchInput["execution"]; type PromptObservationPhaseInput = Omit; type PromptToolSurface = ReturnType; type PromptPreflightPhaseInput = PromptDispatchInput["preflight"] & { @@ -54,14 +54,13 @@ type PromptSubmissionPhaseInput = Pick< | "toolResultPromptProjectionState" | "trajectoryRecorder" >; -type WithOwnedSessionWriteLock = (operation: () => Promise | T) => Promise; +type WithOwnedTranscriptWrite = (operation: () => Promise | T) => Promise; 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") { diff --git a/src/agents/embedded-agent-runner/run/attempt-session-cleanup.test.ts b/src/agents/embedded-agent-runner/run/attempt-session-cleanup.test.ts index 71e27eda277d..d394e7e43a4c 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session-cleanup.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session-cleanup.test.ts @@ -29,9 +29,9 @@ const attempt = { } as never; function createInput(overrides: Record = {}) { - 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 = {}) { }; 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); - }); }); diff --git a/src/agents/embedded-agent-runner/run/attempt-session-cleanup.ts b/src/agents/embedded-agent-runner/run/attempt-session-cleanup.ts index e01fdc6438ea..54ea96d7e986 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session-cleanup.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session-cleanup.ts @@ -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 ->; +type AttemptTranscriptLifecycle = ReturnType; type TrajectoryRecorder = ReturnType; type DisposableRuntime = { dispose(): Promise | void }; @@ -27,7 +22,7 @@ type CleanupEmbeddedAttemptSessionInput = { attempt: EmbeddedRunAttemptParams; session?: AgentSession; sessionManager?: ReturnType; - 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 { @@ -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")); } diff --git a/src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.test.ts b/src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.test.ts deleted file mode 100644 index 61e65b52e85b..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.test.ts +++ /dev/null @@ -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[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 (operation: () => Promise | 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) | 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(); - }); -}); diff --git a/src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.ts deleted file mode 100644 index 523a139fe98b..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt-session-lock-prepare.ts +++ /dev/null @@ -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 ->; -type OwnedTranscriptWriteContext = Parameters[0]; -type WithOwnedSessionWriteLock = (operation: () => Promise | T) => Promise; - -export async function prepareEmbeddedAttemptSessionLock(input: { - attempt: Pick< - EmbeddedRunAttemptParams, - | "abortSignal" - | "config" - | "runId" - | "sessionFile" - | "sessionId" - | "sessionKey" - | "sessionTarget" - >; - externalAbortController: { - arm: () => void; - throwIfFiredAfterPrepCleanup: () => Promise; - }; - getSessionManager: () => ReturnType | undefined; - onSessionFileOwnerAcquired: (owner: EmbeddedAttemptSessionFileOwner) => void; - onSessionLockReleaseReady: (release: () => Promise) => 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, - }; -} diff --git a/src/agents/embedded-agent-runner/run/attempt-session-manager-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-session-manager-prepare.ts index 15c961398830..37acebec1dad 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session-manager-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session-manager-prepare.ts @@ -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; -type WithOwnedSessionWriteLock = (operation: () => Promise | T) => Promise; +type WithOwnedTranscriptWrite = (operation: () => Promise | T) => Promise; export async function prepareEmbeddedAttemptSessionManager(input: { attempt: EmbeddedRunAttemptParams; activeContextEngine?: AttemptContextEngine; @@ -32,8 +32,8 @@ export async function prepareEmbeddedAttemptSessionManager(input: { replayAllowedToolNames: ReadonlySet; 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, diff --git a/src/agents/embedded-agent-runner/run/attempt-session-runtime-prepare.test.ts b/src/agents/embedded-agent-runner/run/attempt-session-runtime-prepare.test.ts index ee3ca3e6dafd..f87c3c768f47 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session-runtime-prepare.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session-runtime-prepare.test.ts @@ -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", diff --git a/src/agents/embedded-agent-runner/run/attempt-session-runtime-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-session-runtime-prepare.ts index 0914d57ee88b..68ddaf4675a4 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session-runtime-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session-runtime-prepare.ts @@ -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; diff --git a/src/agents/embedded-agent-runner/run/attempt-session.test.ts b/src/agents/embedded-agent-runner/run/attempt-session.test.ts index fc8a4f10728a..b78356fc3fbe 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session.test.ts @@ -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?.(), diff --git a/src/agents/embedded-agent-runner/run/attempt-session.ts b/src/agents/embedded-agent-runner/run/attempt-session.ts index 62a5ec87af0c..dcb07d078006 100644 --- a/src/agents/embedded-agent-runner/run/attempt-session.ts +++ b/src/agents/embedded-agent-runner/run/attempt-session.ts @@ -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. diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-finalize.test.ts b/src/agents/embedded-agent-runner/run/attempt-stream-finalize.test.ts index f5558f6d096d..484d1b3eb52f 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-finalize.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-finalize.test.ts @@ -43,13 +43,7 @@ function createFixture(overrides?: Partial) { 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({ diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-finalize.ts b/src/agents/embedded-agent-runner/run/attempt-stream-finalize.ts index e4aa58da67f6..af2b973971a4 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-finalize.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-finalize.ts @@ -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; repairedRejectedThinkingReplay: boolean; getRunAbortDeadlineAtMs: () => number; @@ -51,7 +48,7 @@ export async function finalizeEmbeddedAttemptStreamPhase(input: { >; afterTurn: Omit; }): 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, diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-prepare.test.ts b/src/agents/embedded-agent-runner/run/attempt-stream-prepare.test.ts index e163eec1fec6..b9182a07ca9b 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-prepare.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-prepare.test.ts @@ -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 { diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-runtime-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-stream-runtime-prepare.ts index ada4322c4968..4df5c5c6682e 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-runtime-prepare.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-runtime-prepare.ts @@ -33,7 +33,6 @@ type StreamGuardPhaseInput = Omit< | "onIdleTimeout" | "onRejectedThinkingReplayRepaired" | "session" - | "sessionLockController" | "sessionManager" >; type HistoryPhaseInput = Omit; @@ -54,7 +53,6 @@ export async function prepareEmbeddedAttemptStreamRuntime(input: { activeSession: StreamInput["activeSession"]; sessionManager: HistoryInput["sessionManager"] & NonNullable; - sessionLockController: StreamGuardInput["sessionLockController"]; ownedTranscriptWriteContext: Parameters[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); diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-settle.test.ts b/src/agents/embedded-agent-runner/run/attempt-stream-settle.test.ts index 0aaacb5f8203..f8d8c9e9741c 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-settle.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-settle.test.ts @@ -26,8 +26,7 @@ function createSettleFixture(overrides?: Partial): SettleInput { messages: [], }, sessionManager, - sessionLockController: {}, - withOwnedSessionWriteLock: async (operation: () => unknown) => await operation(), + withOwnedTranscriptWrite: async (operation: () => unknown) => await operation(), subscription: { toolMetas: [], waitForCompactionRetry: async () => {}, diff --git a/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts b/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts index 0fa66b06c2a4..8e2ebf92daa6 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream-settle.ts @@ -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; -type AttemptSessionLockController = Awaited< - ReturnType ->; type PromptCacheRetention = Parameters[0]["retention"]; type ToolSearchTargetTranscriptProjections = Parameters< typeof projectToolSearchTargetTranscriptMessages >[1]; -type WithOwnedSessionWriteLock = (operation: () => Promise | T) => Promise; +type WithOwnedTranscriptWrite = (operation: () => Promise | T) => Promise; 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({ diff --git a/src/agents/embedded-agent-runner/run/attempt-stream.ts b/src/agents/embedded-agent-runner/run/attempt-stream.ts index 4e16bc70a164..61d78733cfc5 100644 --- a/src/agents/embedded-agent-runner/run/attempt-stream.ts +++ b/src/agents/embedded-agent-runner/run/attempt-stream.ts @@ -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; type AnthropicPayloadLogger = ReturnType; -type AttemptSessionLockController = Awaited< - ReturnType ->; - 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; liveAllowedToolNames: Set; @@ -138,7 +132,6 @@ export function installEmbeddedAttemptStreamGuards(input: { }); if (repair.repaired) { input.onRejectedThinkingReplayRepaired(); - input.sessionLockController.refreshAfterOwnedSessionWrite(); return; } log.warn( diff --git a/src/agents/embedded-agent-runner/run/attempt-timeout-prepare.test.ts b/src/agents/embedded-agent-runner/run/attempt-timeout-prepare.test.ts index c9179c09c6a0..c3d76c325ec2 100644 --- a/src/agents/embedded-agent-runner/run/attempt-timeout-prepare.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-timeout-prepare.test.ts @@ -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 () => { diff --git a/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle-prepare.test.ts b/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle-prepare.test.ts new file mode 100644 index 000000000000..8d46654d5423 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle-prepare.test.ts @@ -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(); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle-prepare.ts b/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle-prepare.ts new file mode 100644 index 000000000000..a705d0005e58 --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle-prepare.ts @@ -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 = (operation: () => Promise | T) => Promise; + +export async function prepareEmbeddedAttemptTranscriptLifecycle(input: { + attempt: Pick< + EmbeddedRunAttemptParams, + | "abortSignal" + | "config" + | "runId" + | "sessionFile" + | "sessionId" + | "sessionKey" + | "sessionTarget" + >; + externalAbortController: { + arm: () => void; + throwIfFiredAfterPrepCleanup: () => Promise; + }; +}): Promise<{ + compactionTimeoutMs: number; + ownedTranscriptWriteContext: OwnedSessionTranscriptWriteContext; + transcriptLifecycle: ReturnType; + 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, + }; +} diff --git a/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle.test.ts b/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle.test.ts new file mode 100644 index 000000000000..1a1ec9e5aefd --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle.test.ts @@ -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((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", + ); + }); + }); +}); diff --git a/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle.ts b/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle.ts new file mode 100644 index 000000000000..7f3472a6a4ec --- /dev/null +++ b/src/agents/embedded-agent-runner/run/attempt-transcript-lifecycle.ts @@ -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; + pendingOperations: Set>; +}; + +export type EmbeddedAttemptTranscriptLifecycle = { + withTranscriptWrite(run: () => Promise | T): Promise; + beginCleanup(): Promise; + dispose(): Promise; +}; + +export function createEmbeddedAttemptTranscriptLifecycle(params: { + runId?: string; + sessionId?: string; +}): EmbeddedAttemptTranscriptLifecycle { + let cleanupRequested = false; + let disposed = false; + let lifecycle = Promise.resolve(); + let cleanupDrain: Promise | undefined; + let disposePromise: Promise | undefined; + let pendingWrites = 0; + let teardownBudgetLogged = false; + const lifecycleOwner = new AsyncLocalStorage(); + + const createLifecycleOwner = (): LifecycleOwner => ({ + active: true, + nestedPending: 0, + nestedTail: Promise.resolve(), + pendingOperations: new Set(), + }); + const drainLifecycleOwner = async (owner: LifecycleOwner): Promise => { + 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 ( + owner: LifecycleOwner, + run: () => Promise | T, + ): Promise => { + 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 (run: () => Promise | T): Promise => { + 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((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): Promise => { + let timeout: ReturnType | undefined; + const settled = await Promise.race([ + operation.then(() => true), + new Promise((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 => { + 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; + }, + }; +} diff --git a/src/agents/embedded-agent-runner/run/attempt.abort-race.test.ts b/src/agents/embedded-agent-runner/run/attempt.abort-race.test.ts index f68349022a39..7f09fd1902c5 100644 --- a/src/agents/embedded-agent-runner/run/attempt.abort-race.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.abort-race.test.ts @@ -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((resolve) => { - markLockRequested = resolve; - }); - hoisted.acquireSessionWriteLockMock.mockImplementationOnce(async (params) => { - observedSignal = params.signal; - markLockRequested(); - await new Promise((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((resolve) => { diff --git a/src/agents/embedded-agent-runner/run/attempt.run-decisions.test.ts b/src/agents/embedded-agent-runner/run/attempt.run-decisions.test.ts deleted file mode 100644 index 2803141bf992..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt.run-decisions.test.ts +++ /dev/null @@ -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, - 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); - }); -}); diff --git a/src/agents/embedded-agent-runner/run/attempt.run-decisions.ts b/src/agents/embedded-agent-runner/run/attempt.run-decisions.ts index 890c6f1c63a3..e8a537957cf9 100644 --- a/src/agents/embedded-agent-runner/run/attempt.run-decisions.ts +++ b/src/agents/embedded-agent-runner/run/attempt.run-decisions.ts @@ -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 diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.teardown.test.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.teardown.test.ts deleted file mode 100644 index 33f669ef80ca..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.teardown.test.ts +++ /dev/null @@ -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((resolve) => { - markWriteStarted = resolve; - }); - let resumeWrite!: () => void; - const writeBlocked = new Promise((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((resolve) => { - markWriteStarted = resolve; - }); - let resumeWrite!: () => void; - const writeBlocked = new Promise((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((resolve) => { - markWriteStarted = resolve; - }); - let resumeWrite!: () => void; - const writeBlocked = new Promise((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" }), - ); - }, - ); - }); -}); diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts deleted file mode 100644 index 52b570fa8f77..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts +++ /dev/null @@ -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((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((resolve) => { - resumeDescendant = resolve; - }); - let releaseSecond!: () => void; - const secondBlocked = new Promise((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; - - 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((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((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((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(() => {}), - }); - - 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((resolve) => { - markReloadStarted = resolve; - }); - let finishReload!: () => void; - const reloadBlocked = new Promise((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>() - .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((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; - - 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((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((resolve) => { - markReloadStarted = resolve; - }); - let finishReload!: () => void; - const reloadBlocked = new Promise((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((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(() => {}); - }, - }); - - 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>() - .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((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((resolve) => { - markReloadStarted = resolve; - }); - let finishReload!: () => void; - const reloadBlocked = new Promise((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((_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); - }); -}); diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.ts deleted file mode 100644 index d3f10f966b10..000000000000 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.ts +++ /dev/null @@ -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>; -type AcquireSessionWriteLock = typeof acquireSessionWriteLock; -type SessionKeyLockOptions = Extract< - Parameters[0], - { targetKind: "session-key" } ->; -type LockOptions = Omit & { - 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 { - 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; - isPromptSubmissionBlockedError(error: unknown): boolean; - releaseForPrompt(): Promise; - releaseHeldLockForAbort(options?: { reason?: unknown; terminal?: boolean }): Promise; - refreshAfterOwnedSessionWrite(): void; - reacquireAfterPrompt(): Promise; - withSessionWriteLock( - run: () => Promise | T, - options?: OwnedSessionTranscriptWriteOptions, - ): Promise; - acquireForCleanup(params?: { session?: unknown }): Promise; - hasSessionTakeover(): boolean; - dispose(): Promise; -}; - -export async function createEmbeddedAttemptSessionLockController(params: { - acquireSessionWriteLock: AcquireSessionWriteLock; - initialAcquireSignal?: AbortSignal; - lockOptions: LockOptions; - runId?: string; - sessionId?: string; - reloadPromptReleasedSessionFile?: () => Promise | void; -}): Promise { - // 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 | undefined; - let initialLockReleased = false; - const releaseInitialLock = (): Promise => { - 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 | undefined; - let cleanupReleasePromise: Promise | undefined; - let cleanupLockGranted = false; - let cleanupOwnershipReleased: Promise | undefined; - let resolveCleanupOwnershipReleased: (() => void) | undefined; - let rejectCleanupOwnershipReleased: ((error: unknown) => void) | undefined; - type ActiveWriteOperation = { - active: boolean; - settlement?: Promise; - started: boolean; - }; - const activeWriteOperations = new Set(); - const activeWriteOperation = new AsyncLocalStorage(); - type LifecycleOwner = { - active: boolean; - nestedPending: number; - nestedTail: Promise; - pendingOperations: Set>; - }; - const createLifecycleOwner = (): LifecycleOwner => ({ - active: true, - nestedPending: 0, - nestedTail: Promise.resolve(), - pendingOperations: new Set(), - }); - const lifecycleOwner = new AsyncLocalStorage(); - 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, - deadline: number, - ): Promise => { - const waitMs = Math.max(0, deadline - performance.now()); - if (waitMs <= 0) { - void operation.catch(() => {}); - return false; - } - let timeout: ReturnType | undefined; - try { - return await Promise.race([ - operation.then(() => true), - new Promise((resolve) => { - timeout = setTimeout(() => resolve(false), waitMs); - timeout.unref?.(); - }), - ]); - } finally { - if (timeout) { - clearTimeout(timeout); - } - } - }; - const drainActiveWrites = async (startedOnly: boolean): Promise => { - 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 => { - 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 ( - owner: LifecycleOwner, - run: () => Promise | T, - ): Promise => { - 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 (run: () => Promise | T): Promise => { - 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((resolve) => { - release = resolve; - }); - await previous; - const owner = createLifecycleOwner(); - try { - return await runLifecycleOwner(owner, run); - } finally { - release(); - } - }; - const reloadPromptReleasedState = async (): Promise => { - 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((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 => { - 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((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((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) & { - openclawSessionLockPromptReleaseInstalled?: true; -}; - -type SessionWithAgentPrompt = { - agent?: { streamFn?: PromptReleaseStreamFn }; -}; - -async function settlePromptSubmission(params: { - reacquireAfterPrompt: () => Promise; -}): Promise { - 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; - reacquireAfterPrompt: () => Promise; - sessionFile?: string; - sessionKey?: string; - sessionTarget?: SessionTranscriptWriteLockTarget; - withSessionWriteLock?: ( - run: () => Promise | T, - options?: OwnedSessionTranscriptWriteOptions, - ) => Promise; - 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; -} diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts index a947b5561554..8cd92b2489dc 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.context-engine.test.ts @@ -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); }); }); diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts index a013107d74c3..c6c4f2b90484 100644 --- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts @@ -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; - acquireSessionWriteLockMock: Mock; installToolResultContextGuardMock: UnknownMock; installContextEngineLoopHookMock: UnknownMock; flushPendingToolResultsAfterIdleMock: AsyncUnknownMock; @@ -186,9 +183,6 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => { const subscribeEmbeddedAgentSessionMock = vi.fn(() => createSubscriptionMock(), ); - const acquireSessionWriteLockMock = vi.fn(async (_params) => ({ - release: async () => {}, - })); const resolveBootstrapContextForRunMock = vi.fn<() => Promise>(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[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( "../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); diff --git a/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.test.ts b/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.test.ts index 15c68291b580..0f461041ceb7 100644 --- a/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.test.ts @@ -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((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"]); }); }); diff --git a/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.ts b/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.ts index abba55581f91..c357aeef89d9 100644 --- a/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.ts +++ b/src/agents/embedded-agent-runner/run/attempt.subscription-cleanup.ts @@ -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 }; bundleLspRuntime?: { dispose(): Promise | void }; - sessionLock: { release(): Promise | void }; aborted?: boolean; abortSettlePromise?: Promise | null; - skipSessionFlush?: boolean; runId?: string; sessionId?: string; }): Promise { - 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"); - } } diff --git a/src/agents/embedded-agent-runner/run/attempt.tool-search-catalog-abort.test.ts b/src/agents/embedded-agent-runner/run/attempt.tool-search-catalog-abort.test.ts index 0a8456a80810..d4e3e33832ab 100644 --- a/src/agents/embedded-agent-runner/run/attempt.tool-search-catalog-abort.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.tool-search-catalog-abort.test.ts @@ -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((resolve) => { - markLockRequested = resolve; - }); - hoisted.acquireSessionWriteLockMock.mockImplementationOnce(async (params) => { - markLockRequested(); - await new Promise((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", diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 917a44b5e695..08b2fc3c8e1e 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -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) | undefined; - let retainedSessionFileOwner: EmbeddedAttemptSessionFileOwner | undefined; let bundleMcpRuntime: Awaited> | undefined; let bundleLspRuntime: Awaited> | 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", diff --git a/src/agents/embedded-agent-runner/run/lane-controller.writer-claim.test.ts b/src/agents/embedded-agent-runner/run/lane-controller.writer-claim.test.ts index ad66b887d784..68085228c786 100644 --- a/src/agents/embedded-agent-runner/run/lane-controller.writer-claim.test.ts +++ b/src/agents/embedded-agent-runner/run/lane-controller.writer-claim.test.ts @@ -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", diff --git a/src/agents/embedded-agent-runner/runs.ts b/src/agents/embedded-agent-runner/runs.ts index 2ce3d3069e08..9bf9624e2890 100644 --- a/src/agents/embedded-agent-runner/runs.ts +++ b/src/agents/embedded-agent-runner/runs.ts @@ -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, diff --git a/src/agents/failover-error.test.ts b/src/agents/failover-error.test.ts index 6523352cdbbd..8c4808c987fb 100644 --- a/src/agents/failover-error.test.ts +++ b/src/agents/failover-error.test.ts @@ -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); }); diff --git a/src/agents/failover-error.ts b/src/agents/failover-error.ts index c3efa96b97c4..fd2bc19cd488 100644 --- a/src/agents/failover-error.ts +++ b/src/agents/failover-error.ts @@ -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 = 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 = 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 = 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 = 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 }; diff --git a/src/agents/harness/context-engine-lifecycle.ts b/src/agents/harness/context-engine-lifecycle.ts index ec42a9c26fee..83cf33d77d1c 100644 --- a/src/agents/harness/context-engine-lifecycle.ts +++ b/src/agents/harness/context-engine-lifecycle.ts @@ -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 { 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; withSessionManagerRewriteLock?: (operation: () => Promise | T) => Promise; - config?: SessionWriteLockAcquireTimeoutConfig; + config?: OpenClawConfig; }) { const runtimeSettings = buildHarnessContextEngineRuntimeSettings(params); return await runContextEngineMaintenance({ diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts index 21950c7a7698..45cb08f14117 100644 --- a/src/agents/model-fallback.test.ts +++ b/src/agents/model-fallback.test.ts @@ -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. */ diff --git a/src/agents/session-write-lock-error.ts b/src/agents/session-write-lock-error.ts deleted file mode 100644 index 6964f60e4f2e..000000000000 --- a/src/agents/session-write-lock-error.ts +++ /dev/null @@ -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 - ); -} diff --git a/src/agents/session-write-lock.test-support.ts b/src/agents/session-write-lock.test-support.ts deleted file mode 100644 index 342306153b69..000000000000 --- a/src/agents/session-write-lock.test-support.ts +++ /dev/null @@ -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)[ - 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); - }, -}; diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts deleted file mode 100644 index de6e3aa530c4..000000000000 --- a/src/agents/session-write-lock.test.ts +++ /dev/null @@ -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((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, - }); - }); -}); diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts deleted file mode 100644 index 424993897dda..000000000000 --- a/src/agents/session-write-lock.ts +++ /dev/null @@ -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 } => ({ held: new Map() }), -); - -export type SessionWriteLockAcquireTimeoutConfig = OpenClawConfig; - -type SessionWriteLockMsKey = "acquireTimeoutMs" | "staleMs" | "maxHoldMs"; - -const SESSION_WRITE_LOCK_ENV: Record = { - 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 | 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 }> { - 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 { - 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)[Symbol.for("openclaw.sessionWriteLockTestApi")] = { - resetSessionWriteLockStateForTest, - testing: { - releaseAllLocksSync: releaseAllSessionWriteLeasesSynchronously, - setProcessStartTimeResolverForTest(resolver: ((pid: number) => number | null) | null): void { - resolveProcessStartTimeForLock = resolver ?? defaultProcessStartTimeForLock; - }, - }, - }; -} diff --git a/src/agents/sessions/agent-session-base.ts b/src/agents/sessions/agent-session-base.ts index 58be7e09ab73..956d5061b772 100644 --- a/src/agents/sessions/agent-session-base.ts +++ b/src/agents/sessions/agent-session-base.ts @@ -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; 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(run: () => Promise | T): Promise { - return this.withExternalSessionWriteLock - ? await this.withExternalSessionWriteLock(run) + protected async runWithSessionWriteSettlement(run: () => Promise | T): Promise { + 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); diff --git a/src/agents/sessions/agent-session-compaction.ts b/src/agents/sessions/agent-session-compaction.ts index 7c5feee6f349..6cb326d06ec0 100644 --- a/src/agents/sessions/agent-session-compaction.ts +++ b/src/agents/sessions/agent-session-compaction.ts @@ -41,18 +41,19 @@ export abstract class AgentSessionCompaction extends AgentSessionInspection { * @param customInstructions Optional instructions for the compaction summary */ async compact(customInstructions?: string): Promise { - 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 { - 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 { diff --git a/src/agents/sessions/agent-session-types.ts b/src/agents/sessions/agent-session-types.ts index 918b857247b8..2f3fd31d70cc 100644 --- a/src/agents/sessions/agent-session-types.ts +++ b/src/agents/sessions/agent-session-types.ts @@ -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 = (run: () => Promise | T) => Promise; +export type AgentSessionWriteSettlementRunner = (run: () => Promise | T) => Promise; 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"; } diff --git a/src/agents/sessions/sdk.test.ts b/src/agents/sessions/sdk.test.ts index f5a527279915..32302162ef7e 100644 --- a/src/agents/sessions/sdk.test.ts +++ b/src/agents/sessions/sdk.test.ts @@ -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 Promise>>([ [ @@ -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"]); }); }); diff --git a/src/agents/sessions/sdk.ts b/src/agents/sessions/sdk.ts index fd608341475f..6d23aac05835 100644 --- a/src/agents/sessions/sdk.ts +++ b/src/agents/sessions/sdk.ts @@ -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 (run: () => Promise | T): Promise => - options.withSessionWriteLock ? await options.withSessionWriteLock(run) : await run(); + const runWithSessionWriteSettlement = async (run: () => Promise | T): Promise => + 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(); diff --git a/src/agents/subagent-announce-delivery.test-support.ts b/src/agents/subagent-announce-delivery.test-support.ts index 48b2dbadf087..76c7ddbb505c 100644 --- a/src/agents/subagent-announce-delivery.test-support.ts +++ b/src/agents/subagent-announce-delivery.test-support.ts @@ -26,8 +26,8 @@ type DeliveryDeps = { type Testing = { setDepsForTest(overrides?: Partial): 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), }; diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index 27ccb40dd658..db3afd895007 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -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. */ diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index ccfb73f3d906..0452f17a4511 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -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> & { 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)[ diff --git a/src/agents/subagent-registry-lifecycle.test.ts b/src/agents/subagent-registry-lifecycle.test.ts index c26fefee8b81..fdcf3c000598 100644 --- a/src/agents/subagent-registry-lifecycle.test.ts +++ b/src/agents/subagent-registry-lifecycle.test.ts @@ -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((resolve) => { releaseCleanup = resolve; }); - const staleWriteLock = vi.fn(); - const withStaleWriteLock = async (operation: () => Promise | T): Promise => { - staleWriteLock(); + const requesterTranscriptWrite = vi.fn(); + const withRequesterTranscriptWrite = async (operation: () => Promise | T): Promise => { + 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((resolve) => { releaseWake = resolve; }); - const staleWriteLock = vi.fn(); - const withStaleWriteLock = async (operation: () => Promise | T): Promise => { - staleWriteLock(); + const requesterTranscriptWrite = vi.fn(); + const withRequesterTranscriptWrite = async (operation: () => Promise | T): Promise => { + 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(); }); diff --git a/src/agents/subagent-registry.test.ts b/src/agents/subagent-registry.test.ts index 866632cb3988..d41c61bc2452 100644 --- a/src/agents/subagent-registry.test.ts +++ b/src/agents/subagent-registry.test.ts @@ -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>((resolve) => { resolveWait = resolve; }); - const staleWriteLock = vi.fn(); - const withStaleWriteLock = async (operation: () => Promise | T): Promise => { - staleWriteLock(); + const requesterTranscriptWrite = vi.fn(); + const withRequesterTranscriptWrite = async (operation: () => Promise | T): Promise => { + 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({ diff --git a/src/agents/tools/media-generate-background-shared.test.ts b/src/agents/tools/media-generate-background-shared.test.ts index c5c624aa953d..491aba549b03 100644 --- a/src/agents/tools/media-generate-background-shared.test.ts +++ b/src/agents/tools/media-generate-background-shared.test.ts @@ -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 | undefined; - const staleWriteLock = vi.fn(); - const withStaleWriteLock = async (operation: () => Promise | T): Promise => { - staleWriteLock(); + const requesterTranscriptWrite = vi.fn(); + const withRequesterTranscriptWrite = async (operation: () => Promise | T): Promise => { + 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(); diff --git a/src/agents/tools/sessions-send-tool.a2a.test.ts b/src/agents/tools/sessions-send-tool.a2a.test.ts index aabb0e4e79fd..68fc8824db00 100644 --- a/src/agents/tools/sessions-send-tool.a2a.test.ts +++ b/src/agents/tools/sessions-send-tool.a2a.test.ts @@ -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, }); diff --git a/src/auto-reply/reply/agent-runner-error-handler.ts b/src/auto-reply/reply/agent-runner-error-handler.ts index 063339378214..da125ae0c25c 100644 --- a/src/auto-reply/reply/agent-runner-error-handler.ts +++ b/src/auto-reply/reply/agent-runner-error-handler.ts @@ -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; -function isSessionLeaseLoss(error: unknown): boolean { - const pending = [error]; - const seen = new Set(); - 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 || diff --git a/src/auto-reply/reply/agent-runner-execute.ts b/src/auto-reply/reply/agent-runner-execute.ts index a3f8acccc987..77ff949a1811 100644 --- a/src/auto-reply/reply/agent-runner-execute.ts +++ b/src/auto-reply/reply/agent-runner-execute.ts @@ -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, }; diff --git a/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts index f4e89e28a7a5..2a6008ce4fca 100644 --- a/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts @@ -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); diff --git a/src/auto-reply/reply/agent-runner-execution.types.ts b/src/auto-reply/reply/agent-runner-execution.types.ts index a058614defe5..683982ef41bb 100644 --- a/src/auto-reply/reply/agent-runner-execution.types.ts +++ b/src/auto-reply/reply/agent-runner-execution.types.ts @@ -105,7 +105,6 @@ export type AgentTurnParams = { toolProgressDetail?: "explain" | "raw"; replyMediaContext?: ReplyMediaContext; onCompactionNoticePayload?: (payload: ReplyPayload) => Promise | void; - confirmRestartRecoveryArmedAfterLeaseLoss?: () => Promise; isRestartRecoveryArmed?: () => boolean; }; diff --git a/src/auto-reply/reply/agent-runner-run.ts b/src/auto-reply/reply/agent-runner-run.ts index ad038814b2df..d650bb0f41bd 100644 --- a/src/auto-reply/reply/agent-runner-run.ts +++ b/src/auto-reply/reply/agent-runner-run.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index 6f50394395b5..a388e8d5510d 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -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({ diff --git a/src/auto-reply/reply/restart-recovery-claim.test.ts b/src/auto-reply/reply/restart-recovery-claim.test.ts index 1481e728a8df..d6610c04ced5 100644 --- a/src/auto-reply/reply/restart-recovery-claim.test.ts +++ b/src/auto-reply/reply/restart-recovery-claim.test.ts @@ -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(); - }); }); diff --git a/src/auto-reply/reply/restart-recovery-claim.ts b/src/auto-reply/reply/restart-recovery-claim.ts index eba72c441c6d..65644f71bd67 100644 --- a/src/auto-reply/reply/restart-recovery-claim.ts +++ b/src/auto-reply/reply/restart-recovery-claim.ts @@ -34,7 +34,6 @@ type ReplyRestartRecoveryClaimController = { }; }) => Promise; clear: () => Promise; - confirmRestartRecoveryArmedAfterLeaseLoss: () => Promise; 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 => { - 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 => { - 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, }; } diff --git a/src/auto-reply/reply/session-hooks-context.test.ts b/src/auto-reply/reply/session-hooks-context.test.ts index 861dcdf9b00a..890ac84aaed5 100644 --- a/src/auto-reply/reply/session-hooks-context.test.ts +++ b/src/auto-reply/reply/session-hooks-context.test.ts @@ -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( - "../../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 { diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index deb37ef69a3a..ca8a5d43aafb 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -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( - "../../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 () => [ diff --git a/src/cli/gateway-cli/run-loop.ts b/src/cli/gateway-cli/run-loop.ts index 52919be80baa..98a98126f34d 100644 --- a/src/cli/gateway-cli/run-loop.ts +++ b/src/cli/gateway-cli/run-loop.ts @@ -684,8 +684,8 @@ export async function runGatewayLoop(params: { activeRestartSessionKeysAtDrainStart = collectActiveRestartSessionKeys(); activeRestartSessionIdsAtDrainStart = collectActiveRestartSessionIds(); - // Best-effort abort for compacting runs so long compaction operations - // don't hold session write locks across restart boundaries. + // Best-effort abort for compacting runs so transcript settlement does + // not remain pending across restart boundaries. if (activeRuns > 0) { await markActiveMainSessionsForRestart("gateway restart drain"); abortEmbeddedAgentRun(undefined, { mode: "compacting", reason: "restart" }); diff --git a/src/commands/doctor-session-lock-files.ts b/src/commands/doctor-session-lock-files.ts deleted file mode 100644 index cfbe412e4f95..000000000000 --- a/src/commands/doctor-session-lock-files.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** Doctor-only inspection and repair for retired `.jsonl.lock` sidecars. */ -import fsSync from "node:fs"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { scheduler } from "node:timers/promises"; -import { createFileLockManager } from "../infra/file-lock-manager.js"; -import { readGatewayProcessArgsSync } from "../infra/gateway-processes.js"; -import { - inspectSessionLockFileContention, - parseSessionLockFilePayload, - type SessionLockFileInspection, - type SessionLockFilePayload, - type SessionLockOwnerProcessArgsReader, -} from "../infra/session-lock-file-inspection.js"; - -export type SessionLockInspection = SessionLockFileInspection & { - lockPath: string; - removable: boolean; - removed: boolean; -}; - -export type { SessionLockOwnerProcessArgsReader }; - -const LOCK_RECLAIMS = createFileLockManager("openclaw.doctor.session-lock-reclaim"); -const MALFORMED_OWNER_GRACE_MS = 30_000; - -async function readPayload(lockPath: string): Promise { - try { - return parseSessionLockFilePayload(await fs.readFile(lockPath, "utf8")); - } catch { - return null; - } -} - -async function reclaimIfUnchanged(params: { - lockPath: string; - staleMs: number; - nowMs: number; - readOwnerProcessArgs: SessionLockOwnerProcessArgsReader; -}): Promise { - const approve = async (payload: unknown) => - ( - await inspectSessionLockFileContention({ - lockPath: params.lockPath, - payload: payload as SessionLockFilePayload | null, - staleMs: params.staleMs, - nowMs: params.nowMs, - orphanGraceMs: MALFORMED_OWNER_GRACE_MS, - readOwnerProcessArgs: params.readOwnerProcessArgs, - }) - ).removable; - try { - const lock = await LOCK_RECLAIMS.acquire(params.lockPath, { - lockPath: params.lockPath, - staleMs: params.staleMs, - timeoutMs: 0, - retry: { retries: 0 }, - staleRecovery: "remove-if-unchanged", - payload: () => ({ pid: process.pid, createdAt: new Date().toISOString() }), - parsePayload: parseSessionLockFilePayload, - shouldReclaim: ({ payload }) => approve(payload), - shouldRemoveStaleLock: ({ payload }) => approve(payload), - }); - await lock.release(); - return true; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "file_lock_timeout" || code === "file_lock_stale") { - return false; - } - throw error; - } -} - -export async function cleanStaleSessionLockFiles(params: { - sessionsDir: string; - staleMs: number; - removeStale?: boolean; - nowMs?: number; - readOwnerProcessArgs?: SessionLockOwnerProcessArgsReader; -}): Promise<{ locks: SessionLockInspection[]; cleaned: SessionLockInspection[] }> { - const sessionsDir = path.resolve(params.sessionsDir); - const nowMs = params.nowMs ?? Date.now(); - let entries: fsSync.Dirent[]; - try { - entries = await fs.readdir(sessionsDir, { withFileTypes: true }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return { locks: [], cleaned: [] }; - } - throw error; - } - const ownerArgs = params.readOwnerProcessArgs ?? readGatewayProcessArgsSync; - const argsByPid = new Map(); - const readOwnerProcessArgs = (pid: number) => { - if (!argsByPid.has(pid)) { - try { - argsByPid.set(pid, ownerArgs(pid)); - } catch { - // Transient process inspection failures remain retryable for a later lock. - return null; - } - } - return argsByPid.get(pid) ?? null; - }; - const locks: SessionLockInspection[] = []; - const cleaned: SessionLockInspection[] = []; - for (const entry of entries - .filter((candidate) => candidate.name.endsWith(".jsonl.lock")) - .toSorted((left, right) => left.name.localeCompare(right.name))) { - await scheduler.yield(); - const lockPath = path.join(sessionsDir, entry.name); - const { inspection: inspected, removable } = await inspectSessionLockFileContention({ - lockPath, - payload: await readPayload(lockPath), - staleMs: params.staleMs, - nowMs, - orphanGraceMs: MALFORMED_OWNER_GRACE_MS, - readOwnerProcessArgs, - }); - const lock = { lockPath, ...inspected, removable, removed: false }; - if (params.removeStale !== false && removable) { - lock.removed = await reclaimIfUnchanged({ - lockPath, - staleMs: params.staleMs, - nowMs, - readOwnerProcessArgs, - }); - if (lock.removed) { - cleaned.push(lock); - } - } - locks.push(lock); - } - return { locks, cleaned }; -} diff --git a/src/commands/doctor-session-locks.test.ts b/src/commands/doctor-session-locks.test.ts deleted file mode 100644 index bb39eeda3c91..000000000000 --- a/src/commands/doctor-session-locks.test.ts +++ /dev/null @@ -1,387 +0,0 @@ -// Doctor session lock tests cover stale lock detection, repair, and session-store lock diagnostics. -import fs from "node:fs/promises"; -import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { getFileLockProcessStartTime } from "../shared/pid-alive.js"; -import { - createOpenClawTestState, - type OpenClawTestState, -} from "../test-utils/openclaw-test-state.js"; - -const note = vi.hoisted(() => vi.fn()); - -vi.mock("../../packages/terminal-core/src/note.js", () => ({ - note, -})); - -import { - detectStaleSessionLocks, - noteSessionLockHealth, - sessionLockToHealthFinding, - sessionLockToRepairEffect, -} from "./doctor-session-locks.js"; - -async function expectPathMissing(targetPath: string): Promise { - try { - await fs.access(targetPath); - throw new Error(`expected missing path: ${targetPath}`); - } catch (error) { - expect((error as NodeJS.ErrnoException).code).toBe("ENOENT"); - } -} - -function firstNoteCall(): [string, string] { - const call = note.mock.calls[0]; - if (!call) { - throw new Error("expected note call"); - } - return call as [string, string]; -} - -describe("noteSessionLockHealth", () => { - let state: OpenClawTestState; - - beforeEach(async () => { - note.mockClear(); - state = await createOpenClawTestState({ - layout: "state-only", - prefix: "openclaw-doctor-locks-", - }); - }); - - afterEach(async () => { - await state.cleanup(); - }); - - it("reports existing lock files with pid status and age", async () => { - const sessionsDir = state.sessionsDir(); - await fs.mkdir(sessionsDir, { recursive: true }); - const lockPath = path.join(sessionsDir, "active.jsonl.lock"); - await fs.writeFile( - lockPath, - JSON.stringify({ pid: process.pid, createdAt: new Date(Date.now() - 1500).toISOString() }), - "utf8", - ); - - await noteSessionLockHealth({ - shouldRepair: false, - staleMs: 60_000, - readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"], - }); - - expect(note).toHaveBeenCalledTimes(1); - const [message, title] = firstNoteCall(); - expect(title).toBe("Session locks"); - expect(message).toContain("Found 1 session lock file"); - expect(message).toContain(`pid=${process.pid} (alive)`); - expect(message).toContain("stale=no"); - await expect(fs.access(lockPath)).resolves.toBeUndefined(); - }); - - it("removes stale locks in repair mode", async () => { - const sessionsDir = state.sessionsDir(); - await fs.mkdir(sessionsDir, { recursive: true }); - - const staleLock = path.join(sessionsDir, "stale.jsonl.lock"); - const freshLock = path.join(sessionsDir, "fresh.jsonl.lock"); - - await fs.writeFile( - staleLock, - JSON.stringify({ pid: -1, createdAt: new Date(Date.now() - 120_000).toISOString() }), - "utf8", - ); - await fs.writeFile( - freshLock, - JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }), - "utf8", - ); - - await noteSessionLockHealth({ - shouldRepair: true, - staleMs: 30_000, - readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"], - }); - - expect(note).toHaveBeenCalledTimes(1); - const [message] = firstNoteCall(); - expect(message).toContain("[removed]"); - expect(message).toContain("Removed 1 stale session lock file"); - - await expectPathMissing(staleLock); - await expect(fs.access(freshLock)).resolves.toBeUndefined(); - }); - - it("detects stale locks without removing them for structured lint", async () => { - const sessionsDir = state.sessionsDir(); - await fs.mkdir(sessionsDir, { recursive: true }); - - const staleLock = path.join(sessionsDir, "stale.jsonl.lock"); - const freshLock = path.join(sessionsDir, "fresh.jsonl.lock"); - - await fs.writeFile( - staleLock, - JSON.stringify({ pid: -1, createdAt: new Date(Date.now() - 120_000).toISOString() }), - "utf8", - ); - await fs.writeFile( - freshLock, - JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }), - "utf8", - ); - - const locks = await detectStaleSessionLocks({ - staleMs: 30_000, - readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"], - }); - - expect(locks).toHaveLength(1); - expect(locks[0]?.lockPath).toBe(staleLock); - await expect(fs.access(staleLock)).resolves.toBeUndefined(); - await expect(fs.access(freshLock)).resolves.toBeUndefined(); - }); - - it("maps stale locks to structured findings and dry-run effects", async () => { - const sessionsDir = state.sessionsDir(); - await fs.mkdir(sessionsDir, { recursive: true }); - const lockPath = path.join(sessionsDir, "stale.jsonl.lock"); - await fs.writeFile( - lockPath, - JSON.stringify({ pid: -1, createdAt: new Date(Date.now() - 120_000).toISOString() }), - "utf8", - ); - - const [lock] = await detectStaleSessionLocks({ - staleMs: 30_000, - readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"], - }); - if (!lock) { - throw new Error("expected stale session lock"); - } - - expect(sessionLockToHealthFinding(lock)).toEqual( - expect.objectContaining({ - checkId: "core/doctor/session-locks", - severity: "warning", - path: lockPath, - }), - ); - expect(sessionLockToRepairEffect(lock)).toEqual({ - kind: "state", - action: "would-remove-stale-session-lock", - target: lockPath, - dryRunSafe: false, - }); - }); - - it("preserves fresh malformed stale locks in dry-run repair effects", async () => { - const sessionsDir = state.sessionsDir(); - await fs.mkdir(sessionsDir, { recursive: true }); - - const malformedLock = path.join(sessionsDir, "malformed.jsonl.lock"); - await fs.writeFile(malformedLock, "{}", "utf8"); - - const [lock] = await detectStaleSessionLocks({ - staleMs: 30_000, - readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"], - }); - if (!lock) { - throw new Error("expected stale session lock"); - } - - expect(lock.staleReasons).toEqual(["missing-pid", "invalid-createdAt"]); - expect(lock.removable).toBe(false); - expect(sessionLockToHealthFinding(lock).fixHint).toContain("after the cleanup grace period"); - expect(sessionLockToRepairEffect(lock)).toEqual({ - kind: "state", - action: "would-preserve-mtime-gated-stale-session-lock", - target: malformedLock, - dryRunSafe: false, - }); - await expect(fs.access(malformedLock)).resolves.toBeUndefined(); - }); - - it("uses the supplied env to choose the structured lint state dir", async () => { - const other = await createOpenClawTestState({ - layout: "state-only", - prefix: "openclaw-doctor-locks-other-", - applyEnv: false, - }); - try { - await fs.mkdir(other.sessionsDir(), { recursive: true }); - const lockPath = path.join(other.sessionsDir(), "other-stale.jsonl.lock"); - await fs.writeFile( - lockPath, - JSON.stringify({ pid: -1, createdAt: new Date(Date.now() - 120_000).toISOString() }), - "utf8", - ); - - const locks = await detectStaleSessionLocks({ - env: other.env, - staleMs: 30_000, - readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"], - }); - - expect(locks.map((lock) => lock.lockPath)).toEqual([lockPath]); - } finally { - await other.cleanup(); - } - }); - - it("preserves report-only live OpenClaw locks in dry-run repair effects", async () => { - const sessionsDir = state.sessionsDir(); - await fs.mkdir(sessionsDir, { recursive: true }); - - const reportOnlyLock = path.join(sessionsDir, "report-only.jsonl.lock"); - await fs.writeFile( - reportOnlyLock, - JSON.stringify({ pid: process.pid, createdAt: new Date(Date.now() - 45_000).toISOString() }), - "utf8", - ); - - const [lock] = await detectStaleSessionLocks({ - staleMs: 30_000, - readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"], - }); - if (!lock) { - throw new Error("expected stale session lock"); - } - - expect(lock.staleReasons).toEqual(["too-old"]); - expect(sessionLockToHealthFinding(lock).fixHint).toBe( - "OpenClaw is preserving this live owned lock; inspect the owning process if it appears stuck.", - ); - expect(sessionLockToRepairEffect(lock)).toEqual({ - kind: "state", - action: "would-preserve-report-only-stale-session-lock", - target: reportOnlyLock, - dryRunSafe: false, - }); - await expect(fs.access(reportOnlyLock)).resolves.toBeUndefined(); - }); - - it("ignores retired holder max-hold metadata", async () => { - const sessionsDir = state.sessionsDir(); - await fs.mkdir(sessionsDir, { recursive: true }); - const lockPath = path.join(sessionsDir, "max-hold.jsonl.lock"); - await fs.writeFile( - lockPath, - JSON.stringify({ - pid: process.pid, - createdAt: new Date(Date.now() - 1_000).toISOString(), - maxHoldMs: 1, - }), - ); - - await expect( - detectStaleSessionLocks({ - staleMs: 60_000, - readOwnerProcessArgs: () => ["openclaw", "doctor"], - }), - ).resolves.toEqual([]); - await expect(fs.access(lockPath)).resolves.toBeUndefined(); - }); - - it("uses the emergency stale-threshold environment override without removing live OpenClaw lock files", async () => { - const sessionsDir = state.sessionsDir(); - await fs.mkdir(sessionsDir, { recursive: true }); - - const configuredStaleLock = path.join(sessionsDir, "configured-stale.jsonl.lock"); - await fs.writeFile( - configuredStaleLock, - JSON.stringify({ pid: process.pid, createdAt: new Date(Date.now() - 45_000).toISOString() }), - "utf8", - ); - - await noteSessionLockHealth({ - shouldRepair: true, - env: { OPENCLAW_SESSION_WRITE_LOCK_STALE_MS: "30000" }, - readOwnerProcessArgs: () => ["node", "/opt/openclaw/openclaw.mjs", "doctor"], - }); - - expect(note).toHaveBeenCalledTimes(1); - const [message] = firstNoteCall(); - expect(message).toContain("stale=yes (too-old)"); - expect(message).not.toContain("[removed]"); - await expect(fs.access(configuredStaleLock)).resolves.toBeUndefined(); - }); - - it("removes fresh live locks when the owner is not an OpenClaw process", async () => { - const sessionsDir = state.sessionsDir(); - await fs.mkdir(sessionsDir, { recursive: true }); - - const falseLiveLock = path.join(sessionsDir, "false-live.jsonl.lock"); - await fs.writeFile( - falseLiveLock, - JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }), - "utf8", - ); - - await noteSessionLockHealth({ - shouldRepair: true, - staleMs: 60_000, - readOwnerProcessArgs: () => ["python", "worker.py"], - }); - - expect(note).toHaveBeenCalledTimes(1); - const [message] = firstNoteCall(); - expect(message).toContain("stale=yes (non-openclaw-owner)"); - expect(message).toContain("[removed]"); - expect(message).toContain("Removed 1 stale session lock file"); - await expect(fs.access(falseLiveLock)).rejects.toThrow(); - }); - - it.runIf(process.platform !== "win32")( - "removes an untracked current-process sidecar with matching identity", - async () => { - const starttime = getFileLockProcessStartTime(process.pid); - if (starttime === null) { - return; - } - const sessionsDir = state.sessionsDir(); - await fs.mkdir(sessionsDir, { recursive: true }); - const lockPath = path.join(sessionsDir, "orphan-self.jsonl.lock"); - await fs.writeFile( - lockPath, - JSON.stringify({ - pid: process.pid, - starttime, - createdAt: new Date().toISOString(), - }), - ); - - await noteSessionLockHealth({ - shouldRepair: true, - staleMs: 60_000, - readOwnerProcessArgs: () => ["openclaw", "doctor"], - }); - - expect(firstNoteCall()[0]).toContain("orphan-self-pid"); - await expectPathMissing(lockPath); - }, - ); - - it("retries owner argv inspection after a transient resolver failure", async () => { - const sessionsDir = state.sessionsDir(); - await fs.mkdir(sessionsDir, { recursive: true }); - const first = path.join(sessionsDir, "a.jsonl.lock"); - const second = path.join(sessionsDir, "b.jsonl.lock"); - const payload = JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }); - await Promise.all([fs.writeFile(first, payload), fs.writeFile(second, payload)]); - const readOwnerProcessArgs = vi - .fn<(pid: number) => string[] | null>() - .mockImplementationOnce(() => { - throw new Error("transient process inspection failure"); - }) - .mockReturnValue(["python", "worker.py"]); - - await noteSessionLockHealth({ - shouldRepair: true, - staleMs: 60_000, - readOwnerProcessArgs, - }); - - expect(readOwnerProcessArgs).toHaveBeenCalledTimes(2); - await expect(fs.access(first)).resolves.toBeUndefined(); - await expectPathMissing(second); - }); -}); diff --git a/src/commands/doctor-session-locks.ts b/src/commands/doctor-session-locks.ts deleted file mode 100644 index 11aed15bc9b7..000000000000 --- a/src/commands/doctor-session-locks.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** Doctor diagnostics and cleanup for stale session write lock files. */ -import { note } from "../../packages/terminal-core/src/note.js"; -import { resolveAgentSessionDirs } from "../agents/session-dirs.js"; -import { - resolveSessionWriteLockStaleMs, - type SessionWriteLockAcquireTimeoutConfig, -} from "../agents/session-write-lock.js"; -import { resolveStateDir } from "../config/paths.js"; -import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; -import { shortenHomePath } from "../utils.js"; -import { - cleanStaleSessionLockFiles, - type SessionLockInspection, - type SessionLockOwnerProcessArgsReader, -} from "./doctor-session-lock-files.js"; - -const SESSION_LOCKS_CHECK_ID = "core/doctor/session-locks"; -const REPORT_ONLY_STALE_LOCK_REASONS = new Set(["too-old", "hold-exceeded"]); - -function isReportOnlyStaleLock(lock: SessionLockInspection): boolean { - return ( - lock.staleReasons.length > 0 && - lock.staleReasons.every((reason) => REPORT_ONLY_STALE_LOCK_REASONS.has(reason)) - ); -} - -function formatAge(ageMs: number | null): string { - if (ageMs === null) { - return "unknown"; - } - const seconds = Math.floor(ageMs / 1000); - if (seconds < 60) { - return `${seconds}s`; - } - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - if (minutes < 60) { - return `${minutes}m${remainingSeconds}s`; - } - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return `${hours}h${remainingMinutes}m`; -} - -function formatLockLine(lock: SessionLockInspection): string { - const pidStatus = - lock.pid === null ? "pid=missing" : `pid=${lock.pid} (${lock.pidAlive ? "alive" : "dead"})`; - const ageStatus = `age=${formatAge(lock.ageMs)}`; - const staleStatus = lock.stale - ? `stale=yes (${lock.staleReasons.join(", ") || "unknown"})` - : "stale=no"; - const removedStatus = lock.removed ? " [removed]" : ""; - return `- ${shortenHomePath(lock.lockPath)} ${pidStatus} ${ageStatus} ${staleStatus}${removedStatus}`; -} - -export async function detectStaleSessionLocks(params?: { - config?: SessionWriteLockAcquireTimeoutConfig; - env?: NodeJS.ProcessEnv; - staleMs?: number; - readOwnerProcessArgs?: SessionLockOwnerProcessArgsReader; -}): Promise { - const staleMs = params?.staleMs ?? resolveSessionWriteLockStaleMs(params?.config, params?.env); - const env = params?.env ?? process.env; - const sessionDirs = await resolveAgentSessionDirs(resolveStateDir(env)); - const staleLocks: SessionLockInspection[] = []; - for (const sessionsDir of sessionDirs) { - const result = await cleanStaleSessionLockFiles({ - sessionsDir, - staleMs, - removeStale: false, - readOwnerProcessArgs: params?.readOwnerProcessArgs, - }); - staleLocks.push(...result.locks.filter((lock) => lock.stale)); - } - return staleLocks.toSorted((a, b) => a.lockPath.localeCompare(b.lockPath)); -} - -export function sessionLockToHealthFinding(lock: SessionLockInspection): HealthFinding { - const fixHint = lock.removable - ? 'Run "openclaw doctor --fix" to remove this stale lock file automatically.' - : isReportOnlyStaleLock(lock) - ? "OpenClaw is preserving this live owned lock; inspect the owning process if it appears stuck." - : 'Run "openclaw doctor --fix" after the cleanup grace period if this stale lock remains.'; - return { - checkId: SESSION_LOCKS_CHECK_ID, - severity: "warning", - message: `Stale session lock file: ${shortenHomePath(lock.lockPath)} (${lock.staleReasons.join(", ") || "unknown"})`, - path: lock.lockPath, - fixHint, - }; -} - -export function sessionLockToRepairEffect(lock: SessionLockInspection): HealthRepairEffect { - const action = lock.removable - ? "would-remove-stale-session-lock" - : isReportOnlyStaleLock(lock) - ? "would-preserve-report-only-stale-session-lock" - : "would-preserve-mtime-gated-stale-session-lock"; - return { - kind: "state", - action, - target: lock.lockPath, - dryRunSafe: false, - }; -} - -/** Reports session write locks and removes stale locks when doctor repair is enabled. */ -export async function noteSessionLockHealth(params?: { - shouldRepair?: boolean; - config?: SessionWriteLockAcquireTimeoutConfig; - env?: NodeJS.ProcessEnv; - staleMs?: number; - readOwnerProcessArgs?: SessionLockOwnerProcessArgsReader; -}) { - const shouldRepair = params?.shouldRepair === true; - const staleMs = params?.staleMs ?? resolveSessionWriteLockStaleMs(params?.config, params?.env); - let sessionDirs: string[]; - try { - sessionDirs = await resolveAgentSessionDirs(resolveStateDir(process.env)); - } catch (err) { - note(`- Failed to inspect session lock files: ${String(err)}`, "Session locks"); - return; - } - - if (sessionDirs.length === 0) { - return; - } - - const allLocks: SessionLockInspection[] = []; - for (const sessionsDir of sessionDirs) { - const result = await cleanStaleSessionLockFiles({ - sessionsDir, - staleMs, - removeStale: shouldRepair, - readOwnerProcessArgs: params?.readOwnerProcessArgs, - }); - allLocks.push(...result.locks); - } - - if (allLocks.length === 0) { - return; - } - - const staleCount = allLocks.filter((lock) => lock.stale).length; - const removedCount = allLocks.filter((lock) => lock.removed).length; - const lines: string[] = [ - `- Found ${allLocks.length} session lock file${allLocks.length === 1 ? "" : "s"}.`, - ...allLocks.toSorted((a, b) => a.lockPath.localeCompare(b.lockPath)).map(formatLockLine), - ]; - - if (staleCount > 0 && !shouldRepair) { - lines.push(`- ${staleCount} lock file${staleCount === 1 ? " is" : "s are"} stale.`); - lines.push('- Run "openclaw doctor --fix" to remove stale lock files automatically.'); - } - if (shouldRepair && removedCount > 0) { - lines.push( - `- Removed ${removedCount} stale session lock file${removedCount === 1 ? "" : "s"}.`, - ); - } - - note(lines.join("\n"), "Session locks"); -} diff --git a/src/commands/doctor.fast-path-mocks.ts b/src/commands/doctor.fast-path-mocks.ts index 42ebc2975796..9e40f163d4a4 100644 --- a/src/commands/doctor.fast-path-mocks.ts +++ b/src/commands/doctor.fast-path-mocks.ts @@ -112,10 +112,6 @@ vi.mock("./doctor-install-policy.js", () => ({ noteInstallPolicyHealth: vi.fn().mockResolvedValue(undefined), })); -vi.mock("./doctor-session-locks.js", () => ({ - noteSessionLockHealth: vi.fn().mockResolvedValue(undefined), -})); - vi.mock("./doctor-session-transcripts.js", () => ({ noteSessionTranscriptHealth: vi.fn().mockResolvedValue(undefined), })); diff --git a/src/config/sessions/session-accessor.entry.ts b/src/config/sessions/session-accessor.entry.ts index 61b27c3d79af..07b03ad5c40a 100644 --- a/src/config/sessions/session-accessor.entry.ts +++ b/src/config/sessions/session-accessor.entry.ts @@ -54,7 +54,10 @@ import { canonicalSessionKeyMigrationRequiredError } from "./session-canonical-k import { resolveSessionStorePathForScope } from "./session-store-path.js"; import { normalizeStoreSessionKey, resolveSessionStoreEntry } from "./store-entry.js"; import { resolveAllAgentSessionStoreTargetsSync, type SessionStoreTarget } from "./targets.js"; -import { assertOwnedSessionTranscriptWrite } from "./transcript-write-context.js"; +import { + SessionTranscriptWriterClaimReboundError, + withOwnedSessionTranscriptWriterFence, +} from "./transcript-write-context.js"; import type { SessionEntry } from "./types.js"; export { clearPluginOwnedSessionState }; @@ -85,11 +88,12 @@ export { }; export const ensureSessionEntrySync: typeof ensureSessionEntrySyncRaw = (scope, entry) => { - assertOwnedSessionTranscriptWrite({ - sessionKey: scope.sessionKey, - sessionTarget: scope, - }); - return ensureSessionEntrySyncRaw(scope, entry); + const fencedScope = withOwnedSessionTranscriptWriterFence(scope); + const ensured = ensureSessionEntrySyncRaw(fencedScope, entry); + if (fencedScope.expectedWriterRunId !== undefined && !ensured) { + throw new SessionTranscriptWriterClaimReboundError(scope.sessionKey); + } + return ensured; }; /** Keeps legacy store-key alias resolution behind the entry owner boundary. */ diff --git a/src/config/sessions/session-accessor.sqlite-entry.ts b/src/config/sessions/session-accessor.sqlite-entry.ts index 7c91f4d8ec5d..944c3b68d9e8 100644 --- a/src/config/sessions/session-accessor.sqlite-entry.ts +++ b/src/config/sessions/session-accessor.sqlite-entry.ts @@ -22,6 +22,7 @@ import type { SessionTranscriptInstance, SessionEntryTargetPatchScope, SessionTranscriptReadScope, + SessionTranscriptWriteScope, } from "./session-accessor.sqlite-contract.js"; import { readSqliteSessionEntryCache, @@ -450,7 +451,8 @@ export function replaceSqliteSessionEntrySync( /** Creates a missing session identity without replacing a concurrently owned row. */ export function ensureSqliteSessionEntrySync( - scope: SessionAccessScope, + scope: SessionAccessScope & + Pick, entry: SessionEntry, ): boolean { const resolved = resolveSqliteScope(scope); @@ -463,10 +465,17 @@ export function ensureSqliteSessionEntrySync( previous = readSqliteSessionIdentitySnapshot(database, identityKeys); const existing = readSessionEntryRow(database, resolved.sessionKey)?.entry; if (existing) { + // This branch is a read-only ownership probe. The immediately following + // transcript mutation rechecks the lifecycle and writer claim in its own + // transaction, where a rebound can be reported precisely. owned = existing.sessionId === entry.sessionId; current = previous; return; } + if (scope.expectedWriterRunId !== undefined) { + current = previous; + return; + } writeSessionEntry(database, resolved.sessionKey, entry); current = readSqliteSessionIdentitySnapshot(database, identityKeys); owned = current.get(resolved.sessionKey)?.sessionId === entry.sessionId; diff --git a/src/config/sessions/session-accessor.sqlite-parent-session.ts b/src/config/sessions/session-accessor.sqlite-parent-session.ts index cae103501eb3..bee7df4ac146 100644 --- a/src/config/sessions/session-accessor.sqlite-parent-session.ts +++ b/src/config/sessions/session-accessor.sqlite-parent-session.ts @@ -84,7 +84,7 @@ export async function forkSqliteSessionTranscriptFromParent( // in the source agent database while the child transcript must be owned by // the target agent's database. Two databases cannot share one transaction, // so read the parent branch first, then write the child under the target's - // exclusive session write lock. + // exclusive target-database writer queue. if (!params.parentEntry.sessionId) { return { status: "missing-parent" }; } diff --git a/src/config/sessions/session-accessor.sqlite-transcript-sequences.ts b/src/config/sessions/session-accessor.sqlite-transcript-sequences.ts index 2a0e9041853c..8033c1803d49 100644 --- a/src/config/sessions/session-accessor.sqlite-transcript-sequences.ts +++ b/src/config/sessions/session-accessor.sqlite-transcript-sequences.ts @@ -75,7 +75,7 @@ export function rememberCommittedSqliteTranscriptMessageSequencesInTransaction( } } -/** Resolves final cursors while an ordinary turn still owns its writer lock. */ +/** Resolves final cursors while an ordinary turn still owns its writer transaction. */ export function rememberCommittedSqliteTranscriptMessageSequences( scope: SessionTranscriptTurnWriteContext, messages: readonly TranscriptMessageAppendResult[], diff --git a/src/config/sessions/session-accessor.sqlite-transcript-write.ts b/src/config/sessions/session-accessor.sqlite-transcript-write.ts index d4dfef5629d5..da799b335be1 100644 --- a/src/config/sessions/session-accessor.sqlite-transcript-write.ts +++ b/src/config/sessions/session-accessor.sqlite-transcript-write.ts @@ -153,14 +153,21 @@ export async function rewriteSqliteTranscriptEventRowsExact( /** Fully replaces rows for one transcript synchronously for sync session runtimes. */ export function replaceSqliteTranscriptEventsSync( - scope: SessionTranscriptAccessScope, + scope: SessionTranscriptWriteScope, events: TranscriptEvent[], ): boolean { const resolved = resolveSqliteTranscriptScope(scope); let replaced = false; runOpenClawAgentWriteTransaction((database) => { const fresh = readSessionEntryRow(database, resolved.sessionKey); - if (!fresh || fresh.entry.sessionId !== resolved.sessionId) { + if ( + !fresh || + fresh.entry.sessionId !== resolved.sessionId || + (scope.expectedLifecycleRevision !== undefined && + fresh.entry.lifecycleRevision !== scope.expectedLifecycleRevision) || + (scope.expectedWriterRunId !== undefined && + (fresh.entry as InternalSessionEntry).activeWriterRunId !== scope.expectedWriterRunId) + ) { return; } replaceSqliteTranscriptEventsInTransaction(database, resolved, events); diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index 73b93ac11d5f..b28226d7e93c 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -88,7 +88,10 @@ import { trimSqliteTranscriptForManualCompact, } from "./session-accessor.sqlite.js"; import { resolveSqliteTargetFromSessionStorePath } from "./session-sqlite-target.js"; -import { withOwnedSessionTranscriptWrites } from "./transcript-write-context.js"; +import { + SessionTranscriptWriterClaimReboundError, + withOwnedSessionTranscriptWrites, +} from "./transcript-write-context.js"; import type { InternalSessionEntry, SessionEntry } from "./types.js"; const cleanupArchivedSessionTranscriptsMock = vi.hoisted(() => vi.fn(async () => {})); @@ -4117,7 +4120,7 @@ describe("session accessor seam", () => { await expect(loadTranscriptEvents(scope)).resolves.toEqual([]); }); - it("routes SQLite transcript turn appends through an active owned target lock", async () => { + it("routes SQLite transcript turn appends through an active owned write context", async () => { const scope = { agentId: "main", sessionFile: transcriptPath, @@ -4125,20 +4128,16 @@ describe("session accessor seam", () => { sessionKey: "agent:main:owned-publish", storePath, }; - const publishOptions: Array = []; - const publishedEntryBatches: unknown[][] = []; + let ownedWriteCount = 0; await withOwnedSessionTranscriptWrites( { sessionFile: scope.sessionKey, sessionKey: scope.sessionKey, sessionTarget: scope, - assertOwned: () => undefined, - withSessionWriteLock: async (run, options) => { - publishOptions.push(options?.publishOwnedWrite); - const result = await run(); - publishedEntryBatches.push([...(options?.resolvePublishedEntries?.(result) ?? [])]); - return result; + withTranscriptWrite: async (run) => { + ownedWriteCount += 1; + return await run(); }, }, async () => @@ -4159,39 +4158,44 @@ describe("session accessor seam", () => { }), ); - expect(publishOptions).toEqual([undefined]); - expect(publishedEntryBatches).toEqual([[]]); + expect(ownedWriteCount).toBe(1); await expect(loadTranscriptEvents(scope)).resolves.toHaveLength(2); }); - it("fences matching sync transcript and entry writes before SQLite mutation", async () => { + it("fences matching sync transcript mutations with the admitted writer claim", async () => { const scope = { agentId: "main", sessionId: "session-owned-fence", sessionKey: "agent:main:owned-fence", storePath, }; - const stale = new Error("lease lost"); + replaceSqliteSessionEntrySync(scope, { + activeWriterRunId: "current-run", + sessionId: scope.sessionId, + updatedAt: 1, + } as InternalSessionEntry); await withOwnedSessionTranscriptWrites( { sessionFile: scope.sessionKey, sessionKey: scope.sessionKey, - sessionTarget: scope, - assertOwned: () => { - throw stale; + sessionTarget: { + ...scope, + expectedWriterRunId: "superseded-run", }, - withSessionWriteLock: async (run) => await run(), + withTranscriptWrite: async (run) => await run(), }, async () => { - expect(() => - ensureSessionEntrySync(scope, { sessionId: scope.sessionId, updatedAt: 1 }), - ).toThrow(stale); - expect(() => replaceTranscriptEventsSync(scope, [])).toThrow(stale); + expect(ensureSessionEntrySync(scope, { sessionId: scope.sessionId, updatedAt: 2 })).toBe( + true, + ); + expect(() => replaceTranscriptEventsSync(scope, [])).toThrow( + SessionTranscriptWriterClaimReboundError, + ); }, ); - expect(loadSessionEntry(scope)).toBeUndefined(); + expect(loadSessionEntry(scope)?.updatedAt).toBe(1); await expect(loadTranscriptEvents(scope)).resolves.toEqual([]); }); diff --git a/src/config/sessions/session-accessor.transcript-turn.ts b/src/config/sessions/session-accessor.transcript-turn.ts index 87443e7b510e..e079b57b4708 100644 --- a/src/config/sessions/session-accessor.transcript-turn.ts +++ b/src/config/sessions/session-accessor.transcript-turn.ts @@ -25,7 +25,10 @@ import type { SessionTranscriptTurnPersistOptions, SessionTranscriptTurnPersistResult, } from "./session-accessor.types.js"; -import { runWithOwnedSessionTranscriptWriteLock } from "./transcript-write-context.js"; +import { + getOwnedSessionTranscriptWriterFence, + runWithOwnedSessionTranscriptWrite, +} from "./transcript-write-context.js"; import type { SessionEntry } from "./types.js"; /** Appends one prepared ordered group in the existing transcript turn transaction. */ @@ -106,7 +109,7 @@ export async function persistSessionTranscriptTurn( }, ); } - const appendedMessages = await runWithOwnedSessionTranscriptWriteLock( + const appendedMessages = await runWithOwnedSessionTranscriptWrite( { sessionFile: target.sessionKey, sessionKey: target.sessionKey, @@ -232,7 +235,12 @@ async function persistExpectedSessionTranscriptTurn( sessionKey: resolved.normalizedKey, storePath, }; - const turn = await runWithOwnedSessionTranscriptWriteLock( + const inheritedWriterFence = getOwnedSessionTranscriptWriterFence({ + sessionFile: target.sessionKey, + sessionKey: target.sessionKey, + sessionTarget: target, + }); + const turn = await runWithOwnedSessionTranscriptWrite( { sessionFile: target.sessionKey, sessionKey: target.sessionKey, @@ -249,8 +257,10 @@ async function persistExpectedSessionTranscriptTurn( { config: options.config, cwd: options.cwd, - expectedLifecycleRevision: options.expectedLifecycleRevision, - expectedWriterRunId: options.expectedWriterRunId, + expectedLifecycleRevision: + options.expectedLifecycleRevision ?? inheritedWriterFence?.expectedLifecycleRevision, + expectedWriterRunId: + options.expectedWriterRunId ?? inheritedWriterFence?.expectedWriterRunId, expectedSessionState: options.expectedSessionState, expectedSessionId, atomicGroup: options.atomicGroup, diff --git a/src/config/sessions/session-accessor.transcript.ts b/src/config/sessions/session-accessor.transcript.ts index 251b04db1301..afead50c8351 100644 --- a/src/config/sessions/session-accessor.transcript.ts +++ b/src/config/sessions/session-accessor.transcript.ts @@ -26,7 +26,6 @@ import { import type { SessionTranscriptRuntimeScope, SessionTranscriptReadScope, - SessionTranscriptWriteScope, TranscriptEvent, SessionTranscriptManualTrimResult, SessionTranscriptManualTrimPreflightResult, @@ -35,7 +34,10 @@ import { scanSessionTranscriptTree, selectSessionTranscriptTreePathNodes, } from "./transcript-tree.js"; -import { assertOwnedSessionTranscriptWrite } from "./transcript-write-context.js"; +import { + SessionTranscriptWriterClaimReboundError, + withOwnedSessionTranscriptWriterFence, +} from "./transcript-write-context.js"; // Persisted transcripts have one SQLite owner. Async operations keep direct // exports; sync runtime writes use the ownership-fenced wrappers below. @@ -59,37 +61,41 @@ export { withTranscriptWriteTransaction, }; -function assertOwnedTranscriptScope(scope: SessionTranscriptWriteScope): void { - assertOwnedSessionTranscriptWrite({ - sessionFile: scope.sessionFile, - sessionKey: scope.sessionKey, - sessionTarget: scope, - }); -} - export const appendTranscriptEventSync: typeof appendSqliteTranscriptEventSync = ( scope, event, options, ) => { - assertOwnedTranscriptScope(scope); - return appendSqliteTranscriptEventSync(scope, event, options); + const fencedScope = withOwnedSessionTranscriptWriterFence(scope); + const result = appendSqliteTranscriptEventSync(fencedScope, event, options); + if (fencedScope.expectedWriterRunId !== undefined && !result.ok) { + throw new SessionTranscriptWriterClaimReboundError(scope.sessionKey); + } + return result; }; export const appendTranscriptMessageSync: typeof appendSqliteTranscriptMessageSync = ( scope, options, ) => { - assertOwnedTranscriptScope(scope); - return appendSqliteTranscriptMessageSync(scope, options); + const fencedScope = withOwnedSessionTranscriptWriterFence(scope); + const result = appendSqliteTranscriptMessageSync(fencedScope, options); + if (fencedScope.expectedWriterRunId !== undefined && result === undefined) { + throw new SessionTranscriptWriterClaimReboundError(scope.sessionKey); + } + return result; }; export const replaceTranscriptEventsSync: typeof replaceSqliteTranscriptEventsSync = ( scope, events, ) => { - assertOwnedTranscriptScope(scope); - return replaceSqliteTranscriptEventsSync(scope, events); + const fencedScope = withOwnedSessionTranscriptWriterFence(scope); + const replaced = replaceSqliteTranscriptEventsSync(fencedScope, events); + if (fencedScope.expectedWriterRunId !== undefined && !replaced) { + throw new SessionTranscriptWriterClaimReboundError(scope.sessionKey); + } + return replaced; }; /** Keeps transcript event delivery behind the transcript owner boundary. */ diff --git a/src/config/sessions/session-history-eviction.ts b/src/config/sessions/session-history-eviction.ts index bd593b6b4fb0..1f2a46a747d7 100644 --- a/src/config/sessions/session-history-eviction.ts +++ b/src/config/sessions/session-history-eviction.ts @@ -320,7 +320,7 @@ async function enforceSessionHistoryMaintenanceSerialized( let removedEntries = 0; let removedFiles = 0; if (usage.totalBytes > highWaterBytes) { - // Archive pruning shares the session write lock so a concurrent + // Archive pruning shares the SQLite writer queue so a concurrent // reset/delete cannot race its archive file against the unlink pass. const archiveSweep = await runExclusiveSqliteSessionWrite(resolved, async () => pruneSessionTranscriptArchivesToHighWater({ diff --git a/src/config/sessions/transcript-append.test-support.ts b/src/config/sessions/transcript-append.test-support.ts index 7c2e9bfe991f..112384c7f61c 100644 --- a/src/config/sessions/transcript-append.test-support.ts +++ b/src/config/sessions/transcript-append.test-support.ts @@ -8,10 +8,6 @@ import { redactTranscriptMessage } from "../../agents/transcript-redact.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { redactSecrets } from "../../logging/redact.js"; import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; -import { - acquireSessionWriteLock, - resolveSessionWriteLockOptions, -} from "../../plugin-sdk/session-write-lock-runtime.js"; import { isTranscriptOnlyOpenClawAssistantMessage } from "../../shared/transcript-only-openclaw-assistant.js"; import { createSessionTranscriptHeader } from "./transcript-header.js"; import { serializeJsonlLines } from "./transcript-jsonl.js"; @@ -446,8 +442,6 @@ async function withSessionTranscriptAppendQueue( fn: () => Promise, ): Promise { const queueKey = await resolveTranscriptAppendQueueKey(transcriptPath); - // Per-file queue is in-process only; the external session write lock still owns cross-process - // ordering. return await transcriptAppendQueue.enqueue(queueKey, fn); } @@ -460,7 +454,7 @@ type AppendSessionTranscriptMessageParams = { useRawWhenLinear?: boolean; /** Opt into transcript idempotency lookup; default append stays O(1) for fresh keyed messages. */ idempotencyLookup?: "scan" | "caller-checked"; - /** Runs under the transcript write lock after idempotency replay checks and before append. */ + /** Runs in the transcript append queue after idempotency replay checks and before append. */ prepareMessageAfterIdempotencyCheck?: (message: TMessage) => TMessage | undefined; config?: OpenClawConfig; /** Internal owned-batch hook for publishing a newly created transcript header. */ @@ -494,7 +488,7 @@ export async function appendSessionTranscriptMessage( params: AppendSessionTranscriptMessageParams, ): Promise | undefined> { return await withSessionTranscriptAppendQueue(params.transcriptPath, () => - withSessionTranscriptWriteLock(params, () => appendSessionTranscriptMessageLocked(params)), + appendSessionTranscriptMessageLocked(params), ); } @@ -504,31 +498,15 @@ type AppendSessionTranscriptEventParams = { transcriptPath: string; }; -/** Appends a raw transcript event using the same write lock and FIFO as message appends. */ +/** Appends a raw transcript event using the same FIFO as message appends. */ export async function appendSessionTranscriptEvent( params: AppendSessionTranscriptEventParams, ): Promise { await withSessionTranscriptAppendQueue(params.transcriptPath, () => - withSessionTranscriptWriteLock(params, () => appendSessionTranscriptEventLocked(params)), + appendSessionTranscriptEventLocked(params), ); } -async function withSessionTranscriptWriteLock( - params: Pick, - run: () => Promise | T, -): Promise { - const lock = await acquireSessionWriteLock({ - sessionFile: params.transcriptPath, - ...resolveSessionWriteLockOptions(params.config), - reentrantOwner: `session:${path.resolve(params.transcriptPath)}:append:${randomUUID()}`, - }); - try { - return await run(); - } finally { - await lock.release(); - } -} - async function appendSessionTranscriptEventLocked( params: AppendSessionTranscriptEventParams, ): Promise<{ serializedEntry: string }> { diff --git a/src/config/sessions/transcript-write-context.ts b/src/config/sessions/transcript-write-context.ts index ab89b74fd316..185b4670d0e2 100644 --- a/src/config/sessions/transcript-write-context.ts +++ b/src/config/sessions/transcript-write-context.ts @@ -1,22 +1,9 @@ -// Transcript write contexts let nested append paths reuse an already-owned session write lock. +// Transcript write contexts carry the admitted run fence and teardown tracking +// through nested session-manager callbacks. import { AsyncLocalStorage } from "node:async_hooks"; import path from "node:path"; -type OwnedSessionTranscriptWriteContext = { - sessionFile?: string; - sessionKey?: string; - sessionTarget?: SessionTranscriptWriteLockTarget; - /** Fence each durable mutation at the transaction boundary, not only callback admission. */ - assertOwned(): void; - canAdvanceSessionEntryCache?: (snapshot: OwnedSessionTranscriptCacheSnapshot) => boolean; - publishSessionFileSnapshot?: (snapshot: OwnedSessionTranscriptCacheSnapshot) => boolean; - withSessionWriteLock: ( - run: () => Promise | T, - options?: OwnedSessionTranscriptWriteOptions, - ) => Promise; -}; - -export type SessionTranscriptWriteLockTarget = { +type SessionTranscriptWriteTarget = { agentId?: string; sessionId?: string; sessionKey?: string; @@ -25,29 +12,17 @@ export type SessionTranscriptWriteLockTarget = { expectedWriterRunId?: string; }; -export type OwnedSessionTranscriptWriteOptions = { - publishOwnedWrite?: boolean; - resolvePublishedEntries?: (result: T) => readonly OwnedSessionTranscriptPublishedEntry[]; - resolvePublishedEntriesAfterFailure?: () => readonly OwnedSessionTranscriptPublishedEntry[]; -}; - -type OwnedSessionTranscriptPublishedEntry = - | { kind: "id"; id: string } - | { kind: "header"; serialized: string } - | { kind: "serialized"; serialized: string }; - -export type OwnedSessionTranscriptCacheSnapshot = { - dev: bigint; - ino: bigint; - size: bigint; - mtimeNs: bigint; - ctimeNs: bigint; +export type OwnedSessionTranscriptWriteContext = { + sessionFile?: string; + sessionKey?: string; + sessionTarget?: SessionTranscriptWriteTarget; + withTranscriptWrite: (run: () => Promise | T) => Promise; }; const ownedTranscriptWriteContext = new AsyncLocalStorage(); // Compare concrete files when available; SQLite markers fall back to session -// identity because they are storage references rather than lockable paths. +// identity because they are storage references rather than filesystem paths. function normalizeConcretePathForCompare(value: string | undefined): string | undefined { const trimmed = value?.trim(); if (!trimmed || !path.isAbsolute(trimmed) || !trimmed.endsWith(".jsonl")) { @@ -60,9 +35,9 @@ function contextMatches(params: { context: OwnedSessionTranscriptWriteContext; sessionFile?: string; sessionKey?: string; - sessionTarget?: SessionTranscriptWriteLockTarget; + sessionTarget?: SessionTranscriptWriteTarget; }): boolean { - const normalizeTarget = (target: SessionTranscriptWriteLockTarget | undefined) => { + const normalizeTarget = (target: SessionTranscriptWriteTarget | undefined) => { const agentId = target?.agentId?.trim(); const sessionId = target?.sessionId?.trim(); const sessionKey = target?.sessionKey?.trim(); @@ -98,7 +73,7 @@ function contextMatches(params: { return Boolean(contextSessionKey && sessionKey && contextSessionKey === sessionKey); } -/** Runs transcript writes with an owned write-lock context. */ +/** Runs transcript writes with the admitted run's teardown and writer-fence context. */ export async function withOwnedSessionTranscriptWrites( context: OwnedSessionTranscriptWriteContext, run: () => Promise, @@ -106,19 +81,36 @@ export async function withOwnedSessionTranscriptWrites( return await ownedTranscriptWriteContext.run(context, run); } -/** Runs detached work without retaining an attempt-owned transcript lock. */ +/** Runs detached work without retaining an attempt-owned transcript context. */ export function runWithoutOwnedSessionTranscriptWrites(run: () => T): T { return ownedTranscriptWriteContext.exit(run); } -/** Returns the admitted run fence inherited by nested run-owned transcript writes. */ -export function getOwnedSessionTranscriptWriterFence(): +export function bindOwnedSessionTranscriptWrites( + context: OwnedSessionTranscriptWriteContext, + run: (...args: TArgs) => TResult, +): (...args: TArgs) => TResult { + return (...args) => ownedTranscriptWriteContext.run(context, () => run(...args)); +} + +/** Returns the matching admitted-run fence for a durable write boundary. */ +export function getOwnedSessionTranscriptWriterFence( + params: { + sessionFile?: string; + sessionKey?: string; + sessionTarget?: SessionTranscriptWriteTarget; + } = {}, +): | { expectedLifecycleRevision?: string; expectedWriterRunId: string; } | undefined { - const target = ownedTranscriptWriteContext.getStore()?.sessionTarget; + const context = ownedTranscriptWriteContext.getStore(); + if (!context || (Object.keys(params).length > 0 && !contextMatches({ context, ...params }))) { + return undefined; + } + const target = context.sessionTarget; const expectedWriterRunId = target?.expectedWriterRunId?.trim(); if (!expectedWriterRunId) { return undefined; @@ -130,92 +122,35 @@ export function getOwnedSessionTranscriptWriterFence(): }; } -export function bindOwnedSessionTranscriptWrites( - context: OwnedSessionTranscriptWriteContext, - run: (...args: TArgs) => TResult, -): (...args: TArgs) => TResult { - // Bind callbacks that will run later but must still see the parent write-lock context. - return (...args) => ownedTranscriptWriteContext.run(context, () => run(...args)); +/** Applies the admitted-run fence inherited by a matching synchronous writer. */ +export function withOwnedSessionTranscriptWriterFence( + scope: T, +): T { + const fence = getOwnedSessionTranscriptWriterFence({ + sessionKey: scope.sessionKey, + sessionTarget: scope, + }); + return fence ? { ...scope, ...fence } : scope; } -/** Revalidates a matching attempt-owned lease immediately before durable mutation. */ -export function assertOwnedSessionTranscriptWrite(params: { - sessionFile?: string; - sessionKey?: string; - sessionTarget?: SessionTranscriptWriteLockTarget; -}): void { - const context = ownedTranscriptWriteContext.getStore(); - if (context && contextMatches({ context, ...params })) { - context.assertOwned(); +export class SessionTranscriptWriterClaimReboundError extends Error { + constructor(sessionKey: string | undefined) { + super(`session writer claim changed before transcript persistence: ${sessionKey ?? "unknown"}`); + this.name = "SessionTranscriptWriterClaimReboundError"; } } -export async function runWithOwnedSessionTranscriptWriteLock( +export async function runWithOwnedSessionTranscriptWrite( params: { sessionFile?: string; sessionKey?: string; - sessionTarget?: SessionTranscriptWriteLockTarget; + sessionTarget?: SessionTranscriptWriteTarget; }, run: () => Promise | T, ): Promise { - return await runWithOwnedSessionTranscriptWriteContext(params, run); -} - -export async function acquireOwnedSessionTranscriptWriteLock(params: { - sessionFile?: string; - sessionKey?: string; - sessionTarget?: SessionTranscriptWriteLockTarget; -}): Promise<{ release: () => Promise } | undefined> { const context = ownedTranscriptWriteContext.getStore(); if (!context || !contextMatches({ context, ...params })) { - return undefined; - } - - // Keep the owner callback pending until release so release-shaped callers - // cannot outlive the logical writer lock or leak a tracked nested operation. - let markAcquired!: () => void; - let rejectAcquire!: (error: unknown) => void; - const acquired = new Promise((resolve, reject) => { - markAcquired = resolve; - rejectAcquire = reject; - }); - let releaseOperation!: () => void; - const releaseRequested = new Promise((resolve) => { - releaseOperation = resolve; - }); - const operation = context.withSessionWriteLock(async () => { - markAcquired(); - await releaseRequested; - }); - void operation.catch(rejectAcquire); - await acquired; - - let released = false; - return { - release: async () => { - if (released) { - return; - } - released = true; - releaseOperation(); - await operation; - }, - }; -} - -async function runWithOwnedSessionTranscriptWriteContext( - params: { - sessionFile?: string; - sessionKey?: string; - sessionTarget?: SessionTranscriptWriteLockTarget; - }, - run: () => Promise | T, - options?: OwnedSessionTranscriptWriteOptions, -): Promise { - const context = ownedTranscriptWriteContext.getStore(); - if (!context || !contextMatches({ context, ...params })) { - // No matching owner means the caller is responsible for acquiring its normal lock. return await run(); } - return await context.withSessionWriteLock(run, options); + return await context.withTranscriptWrite(run); } diff --git a/src/config/sessions/transcript.test.ts b/src/config/sessions/transcript.test.ts index e362f00a5f64..aa5882da17f9 100644 --- a/src/config/sessions/transcript.test.ts +++ b/src/config/sessions/transcript.test.ts @@ -33,7 +33,7 @@ import { import { selectSessionTranscriptLeafControlledPath } from "./transcript-tree.js"; import { bindOwnedSessionTranscriptWrites, - runWithOwnedSessionTranscriptWriteLock, + runWithOwnedSessionTranscriptWrite, withOwnedSessionTranscriptWrites, } from "./transcript-write-context.js"; import { @@ -448,7 +448,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { } }); - it("runs matching owned transcript appends through the active session write lock", async () => { + it("runs matching owned transcript appends through the active write context", async () => { await writeTranscriptStore(); const sessionFile = `sqlite:main:${sessionId}:${fixture.storePath()}`; const events: string[] = []; @@ -463,9 +463,8 @@ describe("appendAssistantMessageToSessionTranscript", () => { sessionKey, storePath: fixture.storePath(), }, - assertOwned: () => undefined, - withSessionWriteLock: async (run) => { - events.push("lock"); + withTranscriptWrite: async (run) => { + events.push("owned-write"); return await run(); }, }, @@ -478,10 +477,10 @@ describe("appendAssistantMessageToSessionTranscript", () => { ); expect(result.ok).toBe(true); - expect(events).toEqual(["lock"]); + expect(events).toEqual(["owned-write"]); }); - it("does not reuse owned write locks for a different concrete transcript file", async () => { + it("does not reuse an owned write context for a different concrete transcript file", async () => { const oldSessionFile = resolveSessionTranscriptPathInDir("old-session", fixture.sessionsDir()); const nextSessionFile = resolveSessionTranscriptPathInDir( "next-session", @@ -493,14 +492,13 @@ describe("appendAssistantMessageToSessionTranscript", () => { { sessionFile: oldSessionFile, sessionKey, - assertOwned: () => undefined, - withSessionWriteLock: async (run) => { - events.push("lock"); + withTranscriptWrite: async (run) => { + events.push("owned-write"); return await run(); }, }, async () => - await runWithOwnedSessionTranscriptWriteLock( + await runWithOwnedSessionTranscriptWrite( { sessionFile: nextSessionFile, sessionKey }, () => { events.push("write"); @@ -513,7 +511,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { expect(events).toEqual(["write"]); }); - it("does not reuse owned write locks for the same key in another transcript target", async () => { + it("does not reuse an owned write context for the same key in another transcript target", async () => { const cases = [ [ { agentId: "main", sessionKey: "global", storePath: "/tmp/main.sqlite" }, @@ -532,14 +530,13 @@ describe("appendAssistantMessageToSessionTranscript", () => { sessionFile: ownerTarget.sessionKey, sessionKey: ownerTarget.sessionKey, sessionTarget: ownerTarget, - assertOwned: () => undefined, - withSessionWriteLock: async (run) => { - events.push("lock"); + withTranscriptWrite: async (run) => { + events.push("owned-write"); return await run(); }, }, async () => - await runWithOwnedSessionTranscriptWriteLock( + await runWithOwnedSessionTranscriptWrite( { sessionFile: otherTarget.sessionKey, sessionKey: otherTarget.sessionKey, @@ -557,21 +554,20 @@ describe("appendAssistantMessageToSessionTranscript", () => { } }); - it("keeps matching owned transcript appends locked from bound callbacks", async () => { + it("keeps matching owned transcript appends tracked from bound callbacks", async () => { const sessionFile = resolveSessionTranscriptPathInDir(sessionId, fixture.sessionsDir()); const events: string[] = []; const callback = bindOwnedSessionTranscriptWrites( { sessionFile, sessionKey, - assertOwned: () => undefined, - withSessionWriteLock: async (run) => { - events.push("lock"); + withTranscriptWrite: async (run) => { + events.push("owned-write"); return await run(); }, }, async () => - await runWithOwnedSessionTranscriptWriteLock({ sessionFile, sessionKey }, () => { + await runWithOwnedSessionTranscriptWrite({ sessionFile, sessionKey }, () => { events.push("write"); return "ok"; }), @@ -580,7 +576,7 @@ describe("appendAssistantMessageToSessionTranscript", () => { const result = await callback(); expect(result).toBe("ok"); - expect(events).toEqual(["lock", "write"]); + expect(events).toEqual(["owned-write", "write"]); }); it("uses SQLite identity for malformed persisted sessionFile metadata", async () => { diff --git a/src/flows/doctor-core-checks.test.ts b/src/flows/doctor-core-checks.test.ts index 631641883f08..7b5390fa6090 100644 --- a/src/flows/doctor-core-checks.test.ts +++ b/src/flows/doctor-core-checks.test.ts @@ -1013,30 +1013,6 @@ describe("CORE_HEALTH_CHECKS", () => { }), ); }); - - it("registers stale session locks as a legacy-owned structured check", async () => { - const check = getCheck(createCoreHealthChecks(createDeps()), "core/doctor/session-locks"); - - if (typeof check.repair !== "function") { - throw new Error("expected session lock check repair"); - } - await expect( - check.repair( - { - mode: "fix", - runtime, - cfg: {}, - cwd: "/tmp/openclaw-test-workspace", - }, - [], - ), - ).resolves.toEqual( - expect.objectContaining({ - status: "skipped", - reason: "legacy doctor session lock contribution owns cleanup", - }), - ); - }); }); describe("core/doctor/bootstrap-size", () => { diff --git a/src/flows/doctor-core-checks.ts b/src/flows/doctor-core-checks.ts index 0b3d432e9cec..fd448af4cf52 100644 --- a/src/flows/doctor-core-checks.ts +++ b/src/flows/doctor-core-checks.ts @@ -14,11 +14,6 @@ import { shellCompletionStatusToHealthFindings, shellCompletionStatusToRepairEffects, } from "../commands/doctor-completion.js"; -import { - detectStaleSessionLocks, - sessionLockToHealthFinding, - sessionLockToRepairEffect, -} from "../commands/doctor-session-locks.js"; import { disableUnavailableSkillsInConfig, formatMissingSkillSummary, @@ -62,7 +57,6 @@ const FINAL_CONFIG_VALIDATION_CHECK_ID = "core/doctor/final-config-validation"; const GATEWAY_DAEMON_CHECK_ID = "core/doctor/gateway-daemon"; const GATEWAY_HEALTH_CHECK_ID = "core/doctor/gateway-health"; const GATEWAY_SERVICES_EXTRA_CHECK_ID = "core/doctor/gateway-services/extra"; -const SESSION_LOCKS_CHECK_ID = "core/doctor/session-locks"; const TELEGRAM_GENERAL_TOPIC_CONVERSATIONS_CHECK_ID = "core/doctor/telegram-general-topic-conversations"; const SKILL_WORKSHOP_TOOL_POLICY_CHECK_ID = "core/doctor/skill-workshop-tool-policy"; @@ -1002,33 +996,6 @@ function createGatewayDaemonCheck(deps: CoreHealthCheckDeps): SplitHealthCheckIn }; } -const sessionLocksCheck: SplitHealthCheckInput = { - id: SESSION_LOCKS_CHECK_ID, - kind: "core", - description: "Stale session lock files are represented as structured findings.", - source: "doctor", - defaultEnabled: false, - async detect(ctx) { - return (await detectStaleSessionLocks({ config: ctx.cfg, env: process.env })).map( - sessionLockToHealthFinding, - ); - }, - async repair(ctx) { - const effects = (await detectStaleSessionLocks({ config: ctx.cfg, env: process.env })).map( - sessionLockToRepairEffect, - ); - if (ctx.dryRun === true) { - return { status: "repaired", changes: [], effects }; - } - return { - status: "skipped", - reason: "legacy doctor session lock contribution owns cleanup", - changes: [], - effects, - }; - }, -}; - const browserCheck: HealthCheck = { id: "core/doctor/browser", kind: "core", @@ -1321,7 +1288,6 @@ function createConvertedWorkflowChecks( legacyCronStoreCheck, codexSessionRoutesCheck, telegramGeneralTopicConversationsCheck, - sessionLocksCheck, shellCompletionCheck, uiProtocolFreshnessCheck, gatewayServicesExtraCheck, diff --git a/src/flows/doctor-health-contribution-runners.state.ts b/src/flows/doctor-health-contribution-runners.state.ts index b3624494e839..d9e84d9d2682 100644 --- a/src/flows/doctor-health-contribution-runners.state.ts +++ b/src/flows/doctor-health-contribution-runners.state.ts @@ -125,15 +125,6 @@ export async function runCodexSessionRouteHealth(ctx: DoctorHealthFlowContext): } } -export async function runSessionLocksHealth(ctx: DoctorHealthFlowContext): Promise { - const { noteSessionLockHealth } = await import("../commands/doctor-session-locks.js"); - await noteSessionLockHealth({ - shouldRepair: ctx.prompter.shouldRepair, - config: ctx.cfg, - env: ctx.env, - }); -} - export async function runSessionTranscriptsHealth(ctx: DoctorHealthFlowContext): Promise { const { noteSessionTranscriptHealth } = await import("../commands/doctor-session-transcripts.js"); await noteSessionTranscriptHealth({ diff --git a/src/flows/doctor-health-contributions-initial.ts b/src/flows/doctor-health-contributions-initial.ts index 04c24d09dfce..23843ec2ba57 100644 --- a/src/flows/doctor-health-contributions-initial.ts +++ b/src/flows/doctor-health-contributions-initial.ts @@ -15,7 +15,6 @@ import { runPluginRegistryHealth, runReleaseConfiguredPluginInstallsHealth, runSandboxHealth, - runSessionLocksHealth, runSessionSnapshotsHealth, runSessionTranscriptHeadersHealth, runSessionTranscriptLabelsHealth, @@ -322,12 +321,6 @@ export function resolveInitialDoctorHealthContributions(params: { healthCheckIds: ["core/doctor/telegram-general-topic-conversations"], run: runTelegramGeneralTopicConversationHealth, }), - createDoctorHealthContribution({ - id: "doctor:session-locks", - label: "Session locks", - healthCheckIds: ["core/doctor/session-locks"], - run: runSessionLocksHealth, - }), createDoctorHealthContribution({ id: "doctor:session-transcript-headers", label: "Session transcript headers", diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 4792bc0cd8c0..4052ac229b61 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -439,8 +439,7 @@ dispatchInboundMessageMock.mockImplementation( sessionKey, storePath, }, - assertOwned: () => undefined, - withSessionWriteLock: async () => { + withTranscriptWrite: async () => { mockState.disposedTranscriptWriteAttempts += 1; throw new Error("attempt disposed before transcript write"); }, diff --git a/src/infra/gateway-process-argv.test.ts b/src/infra/gateway-process-argv.test.ts index da575b85613e..e4170a19c5bb 100644 --- a/src/infra/gateway-process-argv.test.ts +++ b/src/infra/gateway-process-argv.test.ts @@ -1,11 +1,6 @@ // Tests gateway process argv parsing for diagnostics. import { describe, expect, it } from "vitest"; -import { - isGatewayArgv, - isOpenClawCommandArgv, - isOpenClawProcessArgv, - parseProcCmdline, -} from "./gateway-process-argv.js"; +import { isGatewayArgv, isOpenClawCommandArgv, parseProcCmdline } from "./gateway-process-argv.js"; describe("parseProcCmdline", () => { it("splits null-delimited argv and trims empty entries", () => { @@ -77,16 +72,3 @@ describe("isOpenClawCommandArgv", () => { expect(isOpenClawCommandArgv(["python", "doctor", "worker.py"], "doctor")).toBe(false); }); }); - -describe("isOpenClawProcessArgv", () => { - it("recognizes installed, source, agent, and gateway process forms", () => { - expect(isOpenClawProcessArgv(["node", "/usr/local/bin/openclaw", "doctor"])).toBe(true); - expect(isOpenClawProcessArgv(["node", "/srv/openclaw/openclaw.mjs", "status"])).toBe(true); - expect(isOpenClawProcessArgv(["node", "/srv/openclaw/dist/index.js", "agent"])).toBe(true); - expect(isOpenClawProcessArgv(["openclaw-gateway"])).toBe(true); - }); - - it("rejects unrelated live processes", () => { - expect(isOpenClawProcessArgv(["python", "worker.py"])).toBe(false); - }); -}); diff --git a/src/infra/gateway-process-argv.ts b/src/infra/gateway-process-argv.ts index 6e3035115d86..d155f8c842dc 100644 --- a/src/infra/gateway-process-argv.ts +++ b/src/infra/gateway-process-argv.ts @@ -31,27 +31,6 @@ export function isOpenClawCommandArgv(args: string[], command: string): boolean return exe.endsWith("/openclaw") || exe === "openclaw"; } -export function isOpenClawProcessArgv(args: string[]): boolean { - if (isGatewayArgv(args, { allowGatewayBinary: true })) { - return true; - } - const normalized = args.map(normalizeProcArg); - const exe = (normalized[0] ?? "").replace(/\.(bat|cmd|exe)$/i, ""); - return ( - exe === "openclaw" || - exe.endsWith("/openclaw") || - normalized.some( - (arg) => - arg === "openclaw" || - arg.endsWith("/openclaw") || - arg === "openclaw.mjs" || - arg.endsWith("/openclaw.mjs"), - ) || - (normalized.includes("agent") && - normalized.some((arg) => ENTRY_CANDIDATES.some((entry) => arg.endsWith(entry)))) - ); -} - export function isGatewayArgv(args: string[], opts?: { allowGatewayBinary?: boolean }): boolean { const normalized = args.map(normalizeProcArg); const exe = (normalized[0] ?? "").replace(/\.(bat|cmd|exe)$/i, ""); diff --git a/src/infra/gateway-processes.test.ts b/src/infra/gateway-processes.test.ts index ea7680122b82..0621d79a8e08 100644 --- a/src/infra/gateway-processes.test.ts +++ b/src/infra/gateway-processes.test.ts @@ -1,11 +1,7 @@ // Covers gateway process discovery across platform process listings. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mockProcessPlatform } from "../test-utils/vitest-spies.js"; -import { - getWindowsPowerShellExePath, - getWindowsSystem32ExePath, - getWindowsWmicExePath, -} from "./windows-install-roots.js"; +import { getWindowsPowerShellExePath, getWindowsSystem32ExePath } from "./windows-install-roots.js"; const spawnSyncMock = vi.hoisted(() => vi.fn()); const readFileSyncMock = vi.hoisted(() => vi.fn()); @@ -64,7 +60,6 @@ vi.mock("../channels/chat-meta.js", () => ({ const { findVerifiedGatewayListenerPidsOnPortSync, formatGatewayPidList, - readGatewayProcessArgsSync, signalVerifiedGatewayPidSync, } = await import("./gateway-processes.js"); @@ -86,85 +81,6 @@ describe("gateway-processes", () => { vi.restoreAllMocks(); }); - it("reads linux process args from /proc and parses cmdlines", () => { - setPlatform("linux"); - readFileSyncMock.mockReturnValue("node\0dist/index.js\0gateway\0run\0"); - parseProcCmdlineMock.mockReturnValue(["node", "dist/index.js", "gateway", "run"]); - - expect(readGatewayProcessArgsSync(4242)).toEqual(["node", "dist/index.js", "gateway", "run"]); - expect(readFileSyncMock).toHaveBeenCalledWith("/proc/4242/cmdline", "utf8"); - expect(parseProcCmdlineMock).toHaveBeenCalledWith("node\0dist/index.js\0gateway\0run\0"); - }); - - it("reads darwin process args from ps output and returns null on ps failure", () => { - setPlatform("darwin"); - spawnSyncMock - .mockReturnValueOnce({ - error: null, - status: 0, - stdout: "node /repo/dist/index.js gateway run\n", - }) - .mockReturnValueOnce({ - error: null, - status: 1, - stdout: "", - }); - - expect(readGatewayProcessArgsSync(123)).toEqual([ - "node", - "/repo/dist/index.js", - "gateway", - "run", - ]); - expect(readGatewayProcessArgsSync(124)).toBeNull(); - expect(spawnSyncMock).toHaveBeenCalledWith("ps", ["-o", "command=", "-p", "123"], { - encoding: "utf8", - killSignal: "SIGKILL", - timeout: 1_000, - }); - }); - - it("falls back from powershell to wmic for windows process args", () => { - setPlatform("win32"); - spawnSyncMock - .mockReturnValueOnce({ - error: new Error("powershell missing"), - status: null, - stdout: "", - }) - .mockReturnValueOnce({ - error: null, - status: 0, - stdout: "CommandLine=node.exe gateway run\r\n", - }); - parseCmdScriptCommandLineMock.mockReturnValue(["node.exe", "gateway", "run"]); - - expect(readGatewayProcessArgsSync(77)).toEqual(["node.exe", "gateway", "run"]); - expect(spawnSyncMock.mock.calls[0]?.[0]).toBe(getWindowsPowerShellExePath()); - expect(spawnSyncMock.mock.calls[1]?.[0]).toBe(getWindowsWmicExePath()); - expect(parseCmdScriptCommandLineMock).toHaveBeenCalledWith("node.exe gateway run"); - }); - - it("decodes UTF-16 WMIC output when reading windows process args", () => { - setPlatform("win32"); - spawnSyncMock - .mockReturnValueOnce({ - error: new Error("powershell missing"), - status: null, - stdout: "", - }) - .mockReturnValueOnce({ - error: null, - status: 0, - stdout: Buffer.from("\uFEFFCommandLine=node.exe gateway run\r\n", "utf16le"), - }); - parseCmdScriptCommandLineMock.mockReturnValue(["node.exe", "gateway", "run"]); - - expect(readGatewayProcessArgsSync(77)).toEqual(["node.exe", "gateway", "run"]); - expect(spawnSyncMock.mock.calls[1]?.[0]).toBe(getWindowsWmicExePath()); - expect(parseCmdScriptCommandLineMock).toHaveBeenCalledWith("node.exe gateway run"); - }); - it("signals only verified gateway processes", () => { setPlatform("linux"); readFileSyncMock.mockReturnValue("node\0gateway\0"); diff --git a/src/infra/gateway-processes.ts b/src/infra/gateway-processes.ts index 0a58e275a616..b5dab8cf8e62 100644 --- a/src/infra/gateway-processes.ts +++ b/src/infra/gateway-processes.ts @@ -14,7 +14,7 @@ import { const GATEWAY_PS_PROBE_TIMEOUT_MS = 1_000; /** Read command argv for a PID using the current platform's process APIs. */ -export function readGatewayProcessArgsSync(pid: number): string[] | null { +function readGatewayProcessArgsSync(pid: number): string[] | null { if (process.platform === "linux") { try { return parseProcCmdline(fsSync.readFileSync(`/proc/${pid}/cmdline`, "utf8")); diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index 30355901ad23..c6e1aa25243d 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -5050,7 +5050,7 @@ describe("deliverOutboundPayloads", () => { const sendMatrix = vi.fn().mockResolvedValue({ messageId: "m1", roomId: "!room:example" }); mocks.appendAssistantMessageToSessionTranscript.mockClear(); mocks.appendAssistantMessageToSessionTranscript.mockRejectedValueOnce( - new Error("session file changed while embedded prompt lock was released"), + new Error("transcript mirror failed after channel delivery"), ); const results = await deliverMatrix({ diff --git a/src/infra/session-lock-file-inspection.ts b/src/infra/session-lock-file-inspection.ts deleted file mode 100644 index 11724b6c68b7..000000000000 --- a/src/infra/session-lock-file-inspection.ts +++ /dev/null @@ -1,144 +0,0 @@ -import fs from "node:fs/promises"; -import { getFileLockProcessStartTime, isPidAlive } from "../shared/pid-alive.js"; -import { isOpenClawProcessArgv } from "./gateway-process-argv.js"; -import { readGatewayProcessArgsSync } from "./gateway-processes.js"; -import { readWindowsProcessStartTimeSync } from "./windows-port-pids.js"; - -export type SessionLockFilePayload = { - pid?: number; - createdAt?: string; - starttime?: number; - maxHoldMs?: number; -}; - -export type SessionLockFileInspection = { - pid: number | null; - pidAlive: boolean; - createdAt: string | null; - ageMs: number | null; - stale: boolean; - staleReasons: string[]; -}; - -export type SessionLockOwnerProcessArgsReader = (pid: number) => string[] | null; - -const REPORT_ONLY_REASONS = new Set(["too-old", "hold-exceeded"]); - -function validNumber(value: unknown): value is number { - return typeof value === "number" && Number.isInteger(value) && value >= 0; -} - -export function readSessionLockProcessStartTime(pid: number): number | null { - return process.platform === "win32" - ? readWindowsProcessStartTimeSync(pid, 1_000) - : getFileLockProcessStartTime(pid); -} - -export function parseSessionLockFilePayload(raw: string): SessionLockFilePayload | null { - try { - const value = JSON.parse(raw) as Record; - return { - ...(validNumber(value.pid) && value.pid > 0 ? { pid: value.pid } : {}), - ...(typeof value.createdAt === "string" ? { createdAt: value.createdAt } : {}), - ...(validNumber(value.starttime) ? { starttime: value.starttime } : {}), - ...(validNumber(value.maxHoldMs) && value.maxHoldMs > 0 - ? { maxHoldMs: value.maxHoldMs } - : {}), - }; - } catch { - return null; - } -} - -function inspectSessionLockFile(params: { - payload: SessionLockFilePayload | null; - staleMs: number; - nowMs: number; - heldByThisProcess?: boolean; - reclaimLockWithoutStarttime?: boolean; - readOwnerProcessArgs?: SessionLockOwnerProcessArgsReader; - respectMaxHold?: boolean; -}): SessionLockFileInspection { - const { payload } = params; - const pid = validNumber(payload?.pid) && payload.pid > 0 ? payload.pid : null; - const pidAlive = pid !== null && isPidAlive(pid); - const createdAt = typeof payload?.createdAt === "string" ? payload.createdAt : null; - const createdAtMs = createdAt ? Date.parse(createdAt) : Number.NaN; - const ageMs = Number.isFinite(createdAtMs) ? Math.max(0, params.nowMs - createdAtMs) : null; - const storedStarttime = validNumber(payload?.starttime) ? payload.starttime : null; - const currentStarttime = - pidAlive && pid !== null && storedStarttime !== null - ? readSessionLockProcessStartTime(pid) - : null; - const recycled = - storedStarttime !== null && currentStarttime !== null && currentStarttime !== storedStarttime; - const staleReasons: string[] = []; - if (pid === null) { - staleReasons.push("missing-pid"); - } else if (!pidAlive) { - staleReasons.push("dead-pid"); - } else if (recycled) { - staleReasons.push("recycled-pid"); - } - if (ageMs === null) { - staleReasons.push("invalid-createdAt"); - } else if (ageMs > params.staleMs) { - staleReasons.push("too-old"); - } - if (params.respectMaxHold && payload?.maxHoldMs && ageMs !== null && ageMs > payload.maxHoldMs) { - staleReasons.push("hold-exceeded"); - } - const orphanSelf = - pid === process.pid && - !params.heldByThisProcess && - !recycled && - (storedStarttime !== null - ? currentStarttime === storedStarttime - : params.reclaimLockWithoutStarttime === true); - if (orphanSelf) { - staleReasons.push("orphan-self-pid"); - } else if (pidAlive && pid !== null && !recycled && !params.heldByThisProcess) { - try { - const args = (params.readOwnerProcessArgs ?? readGatewayProcessArgsSync)(pid); - if (args?.some((arg) => arg.trim()) && !isOpenClawProcessArgv(args)) { - staleReasons.push("non-openclaw-owner"); - } - } catch { - // Unknown live owners are preserved. - } - } - return { pid, pidAlive, createdAt, ageMs, stale: staleReasons.length > 0, staleReasons }; -} - -export async function inspectSessionLockFileContention(params: { - lockPath: string; - payload: SessionLockFilePayload | null; - staleMs: number; - nowMs: number; - orphanGraceMs: number; - heldByThisProcess?: boolean; - reclaimLockWithoutStarttime?: boolean; - readOwnerProcessArgs?: SessionLockOwnerProcessArgsReader; - respectMaxHold?: boolean; -}): Promise<{ inspection: SessionLockFileInspection; report: boolean; removable: boolean }> { - const inspection = inspectSessionLockFile(params); - const malformedOnly = inspection.staleReasons.every( - (reason) => reason === "missing-pid" || reason === "invalid-createdAt", - ); - let report = inspection.stale && !params.heldByThisProcess; - if (report && malformedOnly) { - try { - report = - params.nowMs - (await fs.stat(params.lockPath)).mtimeMs > - Math.min(params.staleMs, params.orphanGraceMs); - } catch (error) { - report = (error as NodeJS.ErrnoException).code !== "ENOENT"; - } - } - return { - inspection, - report, - removable: - report && !inspection.staleReasons.every((reason) => REPORT_ONLY_REASONS.has(reason)), - }; -} diff --git a/src/plugin-sdk/session-write-lock-runtime.test.ts b/src/plugin-sdk/session-write-lock-runtime.test.ts index 1613e0993547..cc27bf1b2688 100644 --- a/src/plugin-sdk/session-write-lock-runtime.test.ts +++ b/src/plugin-sdk/session-write-lock-runtime.test.ts @@ -1,448 +1,42 @@ -import { spawn, spawnSync } from "node:child_process"; -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - SessionWriteLockStaleError, - SessionWriteLockTimeoutError, -} from "../agents/session-write-lock-error.js"; -import { drainSessionWriteLockStateForTest } from "../agents/session-write-lock.js"; -import { readSessionLockProcessStartTime } from "../infra/session-lock-file-inspection.js"; -import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; +import { describe, expect, it } from "vitest"; import { acquireSessionWriteLock, - drainSessionFileWriteLockStateForTest, + resolveSessionWriteLockAcquireTimeoutMs, + resolveSessionWriteLockOptions, } from "./session-write-lock-runtime.js"; -const CLEANUP_SIGNALS = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const; - -function addedSignalListener( - signal: (typeof CLEANUP_SIGNALS)[number], - before: Set<(...args: unknown[]) => void>, -): (() => void) | undefined { - return process - .listeners(signal) - .find((listener) => !before.has(listener as (...args: unknown[]) => void)) as - | (() => void) - | undefined; -} - -describe("Plugin SDK session write-lock adapter", () => { - let root = ""; - - beforeEach(async () => { - root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sdk-session-lock-")); - }); - - afterEach(async () => { - await drainSessionFileWriteLockStateForTest(); - await drainSessionWriteLockStateForTest(); - closeOpenClawAgentDatabasesForTest(); - await fs.rm(root, { recursive: true, force: true }); - }); - - it("retains the shipped default file-artifact contract", async () => { - const sessionFile = path.join(root, "nested", "session.jsonl"); - const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - const payload = JSON.parse(await fs.readFile(`${sessionFile}.lock`, "utf8")) as { - pid: number; - }; - expect(payload.pid).toBe(process.pid); - - await lock.release(); - await expect(fs.access(`${sessionFile}.lock`)).rejects.toMatchObject({ code: "ENOENT" }); - }); - - for (const signal of CLEANUP_SIGNALS) { - it(`synchronously releases sidecars for ${signal} without removing peer listeners`, async () => { - const sessionFile = path.join(root, `${signal}.jsonl`); - const peer = () => {}; - process.on(signal, peer); - const before = new Set(process.listeners(signal) as Array<(...args: unknown[]) => void>); - const kill = vi.spyOn(process, "kill").mockReturnValue(true); - try { - await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - const cleanup = addedSignalListener(signal, before); - expect(cleanup).toBeDefined(); - - cleanup?.(); - - await expect(fs.access(`${sessionFile}.lock`)).rejects.toMatchObject({ code: "ENOENT" }); - await drainSessionFileWriteLockStateForTest(); - expect(new Set(process.listeners(signal))).toEqual(before); - expect(kill).not.toHaveBeenCalled(); - } finally { - kill.mockRestore(); - process.off(signal, peer); - } +describe("Plugin SDK session write-lock compatibility stubs", () => { + it("returns an inert owned handle for legacy file and session-key calls", async () => { + const fileLock = await acquireSessionWriteLock({ + sessionFile: "/retired/session.jsonl", + targetKind: "file", }); - } - - it("does not accumulate cleanup listeners across drain cycles", async () => { - const prime = await acquireSessionWriteLock({ - sessionFile: path.join(root, "listener-prime.jsonl"), - timeoutMs: 500, + const sessionLock = await acquireSessionWriteLock({ + sessionFile: "agent:main:main", + targetKind: "session-key", }); - await prime.release(); - await drainSessionFileWriteLockStateForTest(); - const signalCounts = CLEANUP_SIGNALS.map((signal) => process.listenerCount(signal)); - const exitCount = process.listenerCount("exit"); - for (let cycle = 0; cycle < 2; cycle += 1) { - const lock = await acquireSessionWriteLock({ - sessionFile: path.join(root, `listener-${cycle}.jsonl`), - timeoutMs: 500, - }); - await lock.release(); - await drainSessionFileWriteLockStateForTest(); - } - - expect(CLEANUP_SIGNALS.map((signal) => process.listenerCount(signal))).toEqual(signalCounts); - expect(process.listenerCount("exit")).toBe(exitCount); - }); - - it("reraises a sole termination signal after synchronous cleanup", async () => { - const signal = "SIGTERM"; - const before = new Set(process.listeners(signal) as Array<(...args: unknown[]) => void>); - const sessionFile = path.join(root, "sole-signal.jsonl"); - await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - const cleanup = addedSignalListener(signal, before); - const listenerCount = vi.spyOn(process, "listenerCount").mockReturnValue(1); - const kill = vi.spyOn(process, "kill").mockReturnValue(true); - try { - cleanup?.(); - - await expect(fs.access(`${sessionFile}.lock`)).rejects.toMatchObject({ code: "ENOENT" }); - expect(kill).toHaveBeenCalledWith(process.pid, signal); - } finally { - listenerCount.mockRestore(); - kill.mockRestore(); + for (const lock of [fileLock, sessionLock]) { + expect(() => lock.assertOwned()).not.toThrow(); + await expect(lock.release()).resolves.toBeUndefined(); } }); - it("removes sidecars during real process exit", async () => { - const sessionFile = path.join(root, "process-exit.jsonl"); - const runtimeUrl = pathToFileURL( - path.resolve("src/plugin-sdk/session-write-lock-runtime.ts"), - ).href; - const script = ` - import fs from "node:fs/promises"; - import { acquireSessionWriteLock } from ${JSON.stringify(runtimeUrl)}; - const sessionFile = ${JSON.stringify(sessionFile)}; - await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - await fs.access(sessionFile + ".lock"); - process.exit(0); - `; - const result = spawnSync( - process.execPath, - ["--import", "tsx", "--input-type=module", "-e", script], - { encoding: "utf8", timeout: 10_000 }, - ); - - expect(result.error).toBeUndefined(); - expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); - await expect(fs.access(`${sessionFile}.lock`)).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it("watchdog expiry releases only the lock it owns", async () => { - vi.useFakeTimers(); - vi.stubEnv("VITEST", "false"); - const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); - const sessionFile = path.join(root, "watchdog.jsonl"); - try { - const expired = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, maxHoldMs: 1 }); - await vi.advanceTimersByTimeAsync(60_001); - vi.useRealTimers(); - await expect - .poll( - async () => { - try { - await fs.access(`${sessionFile}.lock`); - return false; - } catch { - return true; - } - }, - { timeout: 1_000 }, - ) - .toBe(true); - expect(warn).toHaveBeenCalledWith( - expect.stringContaining("[session-write-lock] releasing lock held for"), - ); - - const successor = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - await expired.release(); - await expect(fs.access(`${sessionFile}.lock`)).resolves.toBeUndefined(); - await successor.release(); - } finally { - vi.useRealTimers(); - vi.unstubAllEnvs(); - warn.mockRestore(); - } - }); - - it("reference-counts an explicit reentrant owner", async () => { - const sessionFile = path.join(root, "session.jsonl"); - const first = await acquireSessionWriteLock({ - sessionFile, - timeoutMs: 500, - reentrantOwner: "plugin-run", - }); - const second = await acquireSessionWriteLock({ - sessionFile, - timeoutMs: 500, - reentrantOwner: "plugin-run", - }); - - await first.release(); - await expect(fs.access(`${sessionFile}.lock`)).resolves.toBeUndefined(); - await second.release(); - await expect(fs.access(`${sessionFile}.lock`)).rejects.toMatchObject({ code: "ENOENT" }); - }); - - it.runIf(process.platform !== "win32")("canonicalizes symlink aliases", async () => { - const real = path.join(root, "real"); - const alias = path.join(root, "alias"); - await fs.mkdir(real); - await fs.symlink(real, alias); - const owner = "plugin-alias"; - const first = await acquireSessionWriteLock({ - sessionFile: path.join(real, "session.jsonl"), - timeoutMs: 500, - reentrantOwner: owner, - }); - const second = await acquireSessionWriteLock({ - sessionFile: path.join(alias, "session.jsonl"), - timeoutMs: 500, - reentrantOwner: owner, - }); - await Promise.all([first.release(), second.release()]); - }); - - it("reports contention through the stable timeout error", async () => { - const sessionFile = path.join(root, "session.jsonl"); - const held = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - const pending = acquireSessionWriteLock({ sessionFile, timeoutMs: 5 }); - - await expect(pending).rejects.toMatchObject({ - name: "SessionWriteLockTimeoutError", - code: "OPENCLAW_SESSION_WRITE_LOCK_TIMEOUT", - timeoutMs: 5, - lockPath: path.join(await fs.realpath(root), "session.jsonl.lock"), - } satisfies Partial); - await held.release(); - }); - - it("cancels contended infinite admission without affecting the owner", async () => { - const sessionFile = path.join(await fs.realpath(root), "session.jsonl"); - const held = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - const lockPath = `${sessionFile}.lock`; - let observedContention: (() => void) | undefined; - const contention = new Promise((resolve) => { - observedContention = resolve; - }); - // fs-safe 0.5.2 polls contended sidecars via lstat/open descriptors, not - // readFile; observe contention at the lstat seam. - const originalLstat = fs.lstat.bind(fs); - const lstatSpy = vi.spyOn(fs, "lstat").mockImplementation((async (file, options) => { - if (file === lockPath) { - observedContention?.(); - } - return await originalLstat(file, options as never); - }) as typeof fs.lstat); - const abort = new AbortController(); - const reason = new Error("stop requested"); - try { - const pending = acquireSessionWriteLock({ - sessionFile, - timeoutMs: Number.POSITIVE_INFINITY, - signal: abort.signal, - }); - const rejected = expect(pending).rejects.toBe(reason); - await contention; - abort.abort(reason); - - await expect(fs.access(lockPath)).resolves.toBeUndefined(); - await held.release(); - await rejected; - const successor = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - await successor.release(); - await expect(fs.access(lockPath)).rejects.toMatchObject({ code: "ENOENT" }); - } finally { - lstatSpy.mockRestore(); - await held.release(); - } - }); - - it("reclaims a definitely dead file owner", async () => { - const sessionFile = path.join(root, "session.jsonl"); - await fs.writeFile( - `${sessionFile}.lock`, - JSON.stringify({ pid: 2_147_483_647, createdAt: new Date().toISOString() }), - ); - const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - await lock.release(); - }); - - it("reclaims an old payload-less sidecar after the short-admission grace", async () => { - const sessionFile = path.join(root, "session.jsonl"); - await fs.writeFile(`${sessionFile}.lock`, ""); - const old = new Date(Date.now() - 10_000); - await fs.utimes(`${sessionFile}.lock`, old, old); - const lock = await acquireSessionWriteLock({ - sessionFile, - timeoutMs: 500, - staleMs: 60_000, - }); - await lock.release(); - }); - - it("preserves a fresh malformed sidecar", async () => { - const sessionFile = path.join(root, "session.jsonl"); - await fs.writeFile(`${sessionFile}.lock`, "{}"); - await expect( - acquireSessionWriteLock({ sessionFile, timeoutMs: 5, staleMs: 60_000 }), - ).rejects.toBeInstanceOf(SessionWriteLockTimeoutError); - await expect(fs.access(`${sessionFile}.lock`)).resolves.toBeUndefined(); - }); - - it("reclaims an untracked same-process sidecar", async () => { - const sessionFile = path.join(root, "session.jsonl"); - await fs.writeFile( - `${sessionFile}.lock`, - JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }), - ); - const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - await lock.release(); - }); - - it.runIf(process.platform === "linux")( - "emits process starttime and reclaims a recycled-pid sidecar", - async () => { - const starttime = readSessionLockProcessStartTime(process.pid); - expect(starttime).not.toBeNull(); - const sessionFile = path.join(root, "recycled-pid.jsonl"); - await fs.writeFile( - `${sessionFile}.lock`, - JSON.stringify({ - pid: process.pid, - starttime: (starttime ?? 0) + 1, - createdAt: new Date().toISOString(), - }), - ); - - const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - const payload = JSON.parse(await fs.readFile(`${sessionFile}.lock`, "utf8")) as { - pid: number; - starttime: number; - }; - expect(payload).toMatchObject({ pid: process.pid, starttime }); - await lock.release(); - }, - ); - - it("reports an old live OpenClaw owner without removing it", async () => { - const owner = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "openclaw"], { - stdio: "ignore", - }); - if (!owner.pid) { - throw new Error("missing child pid"); - } - const sessionFile = path.join(root, "session.jsonl"); - await fs.writeFile( - `${sessionFile}.lock`, - JSON.stringify({ pid: owner.pid, createdAt: "2000-01-01T00:00:00.000Z" }), - ); - try { - await expect( - acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 1 }), - ).rejects.toBeInstanceOf(SessionWriteLockStaleError); - await expect(fs.access(`${sessionFile}.lock`)).resolves.toBeUndefined(); - } finally { - owner.kill("SIGTERM"); - } - }); - - it("reclaims a live sidecar owned by a non-OpenClaw process", async () => { - const owner = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "worker"], { - stdio: "ignore", - }); - if (!owner.pid) { - throw new Error("missing child pid"); - } - const sessionFile = path.join(root, "session.jsonl"); - await fs.writeFile( - `${sessionFile}.lock`, - JSON.stringify({ pid: owner.pid, createdAt: new Date().toISOString() }), - ); - try { - const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - await lock.release(); - } finally { - owner.kill("SIGTERM"); - } - }); - - it("retries when a reported stale sidecar disappears before diagnostics", async () => { - const owner = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)", "openclaw"], { - stdio: "ignore", - }); - if (!owner.pid) { - throw new Error("missing child pid"); - } - const sessionFile = path.join(root, "session.jsonl"); - const lockPath = `${sessionFile}.lock`; - await fs.writeFile( - lockPath, - JSON.stringify({ pid: owner.pid, createdAt: "2000-01-01T00:00:00.000Z" }), - ); - const originalReadFile = fs.readFile.bind(fs); - let lockReads = 0; - // fs-safe 0.5.2 reads sidecar payloads through pinned descriptors; the - // adapter's stale diagnostics are the only readFile consumer left. Vanish - // the sidecar on that first diagnostics read so the retry path is taken. - const readFile = vi.spyOn(fs, "readFile").mockImplementation((async (file, options) => { - if (typeof file === "string" && path.basename(file) === path.basename(lockPath)) { - lockReads += 1; - await fs.rm(lockPath, { force: true }); - throw Object.assign(new Error("lock disappeared"), { code: "ENOENT" }); - } - return await originalReadFile(file, options as never); - }) as typeof fs.readFile); - try { - const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 1 }); - expect(lockReads).toBeGreaterThanOrEqual(1); - await lock.release(); - } finally { - readFile.mockRestore(); - owner.kill("SIGTERM"); - } - }); - - it("does not reclaim a live owner based only on age", async () => { - const sessionFile = path.join(root, "session.jsonl"); - const held = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); - await new Promise((resolve) => { - setTimeout(resolve, 5); - }); - await expect( - acquireSessionWriteLock({ sessionFile, timeoutMs: 5, staleMs: 1 }), - ).rejects.toBeInstanceOf(SessionWriteLockTimeoutError); - await held.release(); - }); - - it("delegates explicit session keys to SQLite", async () => { - const sessionFile = JSON.stringify([ - "main", - path.join(root, "openclaw-agent.sqlite"), - "sdk-session", - ]); - const first = await acquireSessionWriteLock({ sessionFile, targetKind: "session-key" }); - await expect( - acquireSessionWriteLock({ sessionFile, targetKind: "session-key", timeoutMs: 5 }), - ).rejects.toBeInstanceOf(SessionWriteLockTimeoutError); - await first.release(); + it("retains the historical resolver defaults", () => { + expect( + resolveSessionWriteLockAcquireTimeoutMs(undefined, { + OPENCLAW_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS: "1", + }), + ).toBe(60_000); + expect( + resolveSessionWriteLockOptions(undefined, { + env: { + OPENCLAW_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS: "1", + OPENCLAW_SESSION_WRITE_LOCK_STALE_MS: "1", + OPENCLAW_SESSION_WRITE_LOCK_MAX_HOLD_MS: "1", + }, + maxHoldMsFallback: 1, + }), + ).toEqual({ timeoutMs: 60_000, staleMs: 1_800_000, maxHoldMs: 300_000 }); }); }); diff --git a/src/plugin-sdk/session-write-lock-runtime.ts b/src/plugin-sdk/session-write-lock-runtime.ts index bcfb0a56ac81..48de1e4e55d1 100644 --- a/src/plugin-sdk/session-write-lock-runtime.ts +++ b/src/plugin-sdk/session-write-lock-runtime.ts @@ -1,31 +1,14 @@ -import "../infra/fs-safe-defaults.js"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { scheduler } from "node:timers/promises"; -import { - SessionWriteLockStaleError, - SessionWriteLockTimeoutError, -} from "../agents/session-write-lock-error.js"; -import { - acquireSessionWriteLock as acquireSqliteSessionWriteLock, - resolveSessionWriteLockAcquireTimeoutMs, - resolveSessionWriteLockOptions, - type SessionWriteLockAcquireTimeoutConfig, -} from "../agents/session-write-lock.js"; -import { createFileLockManager } from "../infra/file-lock-manager.js"; -import { - inspectSessionLockFileContention, - parseSessionLockFilePayload, - readSessionLockProcessStartTime, - type SessionLockFilePayload, -} from "../infra/session-lock-file-inspection.js"; -import { isPidAlive } from "../shared/pid-alive.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; -export { - resolveSessionWriteLockAcquireTimeoutMs, - resolveSessionWriteLockOptions, - type SessionWriteLockAcquireTimeoutConfig, -}; +const DEFAULT_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS = 60_000; +const DEFAULT_SESSION_WRITE_LOCK_STALE_MS = 30 * 60 * 1000; +const DEFAULT_SESSION_WRITE_LOCK_MAX_HOLD_MS = 5 * 60 * 1000; + +/** + * @deprecated Session write leases were removed. This compatibility type is scheduled for + * removal in the 2026.10 release train; use the session lane and durable writer claim/fence. + */ +export type SessionWriteLockAcquireTimeoutConfig = OpenClawConfig; type LockParams = { sessionFile: string; @@ -38,237 +21,41 @@ type LockParams = { | { targetKind?: "file"; reentrantOwner?: string; allowReentrant?: never } ); -const FILE_LOCKS = createFileLockManager("openclaw.session-write-lock.sdk-compat"); -const ABORT_POLL_MS = 100; -const WATCHDOG_INTERVAL_MS = 60_000; -const ORPHAN_GRACE_MS = 30_000; -const SHORT_ORPHAN_GRACE_MS = 5_000; -const CLEANUP_SIGNALS = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const; -const signalCleanup = new Map void>(); -let watchdog: NodeJS.Timeout | undefined; - -function positiveMs(value: number | undefined, fallback: number, allowInfinity = false): number { - if (value === Number.POSITIVE_INFINITY) { - return allowInfinity ? value : fallback; - } - return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback; +/** + * @deprecated Session write leases were removed. This compatibility stub is scheduled for + * removal in the 2026.10 release train; use the session lane and durable writer claim/fence. + */ +export function resolveSessionWriteLockAcquireTimeoutMs( + _config?: SessionWriteLockAcquireTimeoutConfig, + _env?: NodeJS.ProcessEnv, +): number { + return DEFAULT_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS; } -async function readOwner(lockPath: string, staleMs: number, orphanGraceMs: number) { - let payload: SessionLockFilePayload | null = null; - let missing = false; - try { - payload = parseSessionLockFilePayload(await fs.readFile(lockPath, "utf8")); - } catch (error) { - missing = (error as NodeJS.ErrnoException).code === "ENOENT"; - // The lock can disappear between acquisition failure and diagnostics. - } - const { inspection, report } = await inspectSessionLockFileContention({ - lockPath, - payload, - staleMs, - nowMs: Date.now(), - orphanGraceMs, - reclaimLockWithoutStarttime: true, - respectMaxHold: true, - }); - const { pid } = inspection; +/** + * @deprecated Session write leases were removed. This compatibility stub is scheduled for + * removal in the 2026.10 release train; use the session lane and durable writer claim/fence. + */ +export function resolveSessionWriteLockOptions( + _config?: SessionWriteLockAcquireTimeoutConfig, + _params: { env?: NodeJS.ProcessEnv; maxHoldMsFallback?: number } = {}, +): { timeoutMs: number; staleMs: number; maxHoldMs: number } { return { - missing, - pid, - owner: pid === null ? "owner=unknown" : `pid=${pid} alive=${isPidAlive(pid)}`, - reasons: report ? inspection.staleReasons : [], + timeoutMs: DEFAULT_SESSION_WRITE_LOCK_ACQUIRE_TIMEOUT_MS, + staleMs: DEFAULT_SESSION_WRITE_LOCK_STALE_MS, + maxHoldMs: DEFAULT_SESSION_WRITE_LOCK_MAX_HOLD_MS, }; } -function throwIfAborted(signal: AbortSignal | undefined): void { - if (!signal?.aborted) { - return; - } - if (signal.reason instanceof Error) { - throw signal.reason; - } - const error = new Error("request aborted", { cause: signal.reason }); - error.name = "AbortError"; - throw error; -} - -function ensureSignalCleanup(): void { - if (signalCleanup.size > 0) { - return; - } - for (const signal of CLEANUP_SIGNALS) { - const listener = () => { - const reraise = process.listenerCount(signal) === 1; - FILE_LOCKS.reset(); - if (watchdog) { - clearInterval(watchdog); - watchdog = undefined; - } - if (reraise) { - process.off(signal, listener); - signalCleanup.delete(signal); - try { - process.kill(process.pid, signal); - } catch {} - } - }; - try { - process.on(signal, listener); - signalCleanup.set(signal, listener); - } catch {} - } -} - -function ensureWatchdog(): void { - if (watchdog || process.env.VITEST === "true") { - return; - } - watchdog = setInterval(() => { - const now = Date.now(); - for (const held of FILE_LOCKS.heldEntries()) { - const maxHoldMs = Number(held.metadata.maxHoldMs); - if (Number.isFinite(maxHoldMs) && now - held.acquiredAt > maxHoldMs) { - console.warn( - `[session-write-lock] releasing lock held for ${now - held.acquiredAt}ms (max=${maxHoldMs}ms): ${held.lockPath}`, - ); - void held.forceRelease().catch(() => undefined); - } - } - }, WATCHDOG_INTERVAL_MS); - watchdog.unref?.(); -} - -async function acquireFileArtifactLock( - params: LockParams & { targetKind?: "file" }, -): Promise<{ release: () => Promise }> { - throwIfAborted(params.signal); - ensureSignalCleanup(); - ensureWatchdog(); - const defaults = resolveSessionWriteLockOptions(); - const timeoutMs = positiveMs(params.timeoutMs, defaults.timeoutMs, true); - const staleMs = positiveMs(params.staleMs, defaults.staleMs); - const maxHoldMs = positiveMs(params.maxHoldMs, defaults.maxHoldMs); - const orphanGraceMs = timeoutMs < ORPHAN_GRACE_MS ? SHORT_ORPHAN_GRACE_MS : ORPHAN_GRACE_MS; - const targetPath = path.resolve(params.sessionFile); - await fs.mkdir(path.dirname(targetPath), { recursive: true }); - const startedAt = Date.now(); - const inspectArtifact = async ( - lockPath: string, - payload: unknown, - nowMs: number, - heldByThisProcess = false, - ) => { - await scheduler.yield(); - return await inspectSessionLockFileContention({ - lockPath, - payload: payload as SessionLockFilePayload | null, - staleMs, - nowMs, - orphanGraceMs, - heldByThisProcess, - reclaimLockWithoutStarttime: true, - respectMaxHold: true, - }); - }; - while (true) { - throwIfAborted(params.signal); - const remainingMs = - timeoutMs === Number.POSITIVE_INFINITY - ? timeoutMs - : Math.max(0, timeoutMs - (Date.now() - startedAt)); - const lockPath = `${targetPath}.lock`; - if (remainingMs <= 0) { - const diagnostics = await readOwner(lockPath, staleMs, orphanGraceMs); - throw new SessionWriteLockTimeoutError({ timeoutMs, owner: diagnostics.owner, lockPath }); - } - try { - const lock = await FILE_LOCKS.acquire(targetPath, { - staleMs, - timeoutMs: params.signal ? Math.min(remainingMs, ABORT_POLL_MS) : remainingMs, - retry: { minTimeout: 50, maxTimeout: 1_000, factor: 1 }, - staleRecovery: "remove-if-unchanged", - reentrantOwner: params.reentrantOwner, - metadata: { maxHoldMs }, - payload: () => { - const starttime = readSessionLockProcessStartTime(process.pid); - return { - pid: process.pid, - createdAt: new Date().toISOString(), - maxHoldMs, - ...(starttime === null ? {} : { starttime }), - }; - }, - parsePayload: parseSessionLockFilePayload, - shouldReclaim: async ({ lockPath: contenderPath, payload, nowMs, heldByThisProcess }) => - (await inspectArtifact(contenderPath, payload, nowMs, heldByThisProcess)).report, - shouldRemoveStaleLock: async ({ lockPath: contenderPath, payload }) => - (await inspectArtifact(contenderPath, payload, Date.now())).removable, - }); - // The signal can fire while fs-safe owns its retry loop. Never return a - // newly acquired sidecar after the caller has cancelled admission. - if (params.signal?.aborted) { - await lock.release().catch(() => undefined); - throwIfAborted(params.signal); - } - return { release: () => lock.release() }; - } catch (error) { - throwIfAborted(params.signal); - const code = (error as NodeJS.ErrnoException).code; - if (params.signal && code === "file_lock_timeout" && remainingMs > ABORT_POLL_MS) { - continue; - } - const errorLockPath = (error as { lockPath?: string }).lockPath ?? `${targetPath}.lock`; - const diagnostics = await readOwner(errorLockPath, staleMs, orphanGraceMs); - if (code === "file_lock_stale") { - if (diagnostics.missing || diagnostics.reasons.length === 0) { - continue; - } - throw new SessionWriteLockStaleError({ - owner: diagnostics.owner, - lockPath: errorLockPath, - staleReasons: diagnostics.reasons, - }); - } - if (code === "file_lock_timeout") { - if ( - diagnostics.pid !== process.pid && - diagnostics.reasons.some((reason) => reason === "too-old" || reason === "hold-exceeded") - ) { - throw new SessionWriteLockStaleError({ - owner: diagnostics.owner, - lockPath: errorLockPath, - staleReasons: diagnostics.reasons, - }); - } - throw new SessionWriteLockTimeoutError({ - timeoutMs, - owner: diagnostics.owner, - lockPath: errorLockPath, - }); - } - throw error; - } - } -} - -/** Acquires the shipped file-artifact lock or the canonical SQLite session lease. */ +/** + * @deprecated Session write leases were removed. This no-op compatibility stub is scheduled + * for removal in the 2026.10 release train; use the session lane and durable writer claim/fence. + */ export async function acquireSessionWriteLock( - params: LockParams, -): Promise<{ assertOwned?: () => void; release: () => Promise }> { - return params.targetKind === "session-key" - ? await acquireSqliteSessionWriteLock(params) - : await acquireFileArtifactLock(params); -} - -export async function drainSessionFileWriteLockStateForTest(): Promise { - await FILE_LOCKS.drain(); - for (const [signal, listener] of signalCleanup) { - process.off(signal, listener); - } - signalCleanup.clear(); - if (watchdog) { - clearInterval(watchdog); - watchdog = undefined; - } + _params: LockParams, +): Promise<{ assertOwned: () => void; release: () => Promise }> { + return { + assertOwned: () => undefined, + release: async () => undefined, + }; } diff --git a/src/state/openclaw-state-lease-owner.ts b/src/state/openclaw-state-lease-owner.ts deleted file mode 100644 index 52739aa28edf..000000000000 --- a/src/state/openclaw-state-lease-owner.ts +++ /dev/null @@ -1,78 +0,0 @@ -export type OpenClawStateLeaseProcessOwner = { - pid: number; - startTime: number | null; - isAlive(pid: number): boolean; - readStartTime(pid: number): number | null; -}; - -export type OpenClawStateLeaseOwnerPayload = Readonly<{ - pid: number; - starttime?: number; -}>; - -export type OpenClawStateLeaseErrorCode = - | "OPENCLAW_STATE_LEASE_INVALID_INPUT" - | "OPENCLAW_STATE_LEASE_TIMEOUT" - | "OPENCLAW_STATE_LEASE_ABORTED" - | "OPENCLAW_STATE_LEASE_LOST" - | "OPENCLAW_STATE_LEASE_STORAGE_FAILED"; - -export class OpenClawStateLeaseError extends Error { - readonly code: OpenClawStateLeaseErrorCode; - declare readonly ownerPayload?: OpenClawStateLeaseOwnerPayload; - - constructor( - message: string, - options: { - code: OpenClawStateLeaseErrorCode; - cause?: unknown; - ownerPayload?: OpenClawStateLeaseOwnerPayload; - }, - ) { - super(message, { cause: options.cause }); - this.name = "OpenClawStateLeaseError"; - this.code = options.code; - if (options.ownerPayload) { - this.ownerPayload = options.ownerPayload; - } - } -} - -export function parseOpenClawStateLeaseOwnerPayload( - payloadJson: string | null, -): OpenClawStateLeaseOwnerPayload | undefined { - let payload: { pid?: unknown; starttime?: unknown } | undefined; - try { - payload = payloadJson ? (JSON.parse(payloadJson) as typeof payload) : undefined; - } catch { - return undefined; - } - const pid = payload?.pid; - if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) { - return undefined; - } - const starttime = payload?.starttime; - return typeof starttime === "number" && Number.isInteger(starttime) && starttime >= 0 - ? { pid, starttime } - : { pid }; -} - -export function processLeaseIsReclaimable( - row: { expires_at: number | null; payload_json: string | null }, - processOwner: OpenClawStateLeaseProcessOwner, -): boolean { - const payload = parseOpenClawStateLeaseOwnerPayload(row.payload_json); - if (!payload) { - // A malformed owner can only be reclaimed after its persisted deadline. - return Number(row.expires_at) <= Date.now(); - } - if (!processOwner.isAlive(payload.pid)) { - return true; - } - const observedStartTime = processOwner.readStartTime(payload.pid); - return ( - payload.starttime !== undefined && - observedStartTime !== null && - payload.starttime !== observedStartTime - ); -} diff --git a/src/state/openclaw-state-lease.test.ts b/src/state/openclaw-state-lease.test.ts index e99fcf4e8807..f26c93186c2c 100644 --- a/src/state/openclaw-state-lease.test.ts +++ b/src/state/openclaw-state-lease.test.ts @@ -174,90 +174,4 @@ describe("OpenClaw state lease", () => { ).rejects.toMatchObject({ code: "OPENCLAW_STATE_LEASE_LOST" }); }); }); - - it("never transfers an expired lease away from its live process owner", async () => { - await withOpenClawTestState({ label: "core-state-process-lease-live-owner" }, async () => { - const processOwner = { - pid: process.pid, - startTime: null, - isAlive: (pid: number) => pid === process.pid, - readStartTime: () => null, - }; - const options = { - scope: "core:test", - key: "live-process-owner", - database: { scope: "shared" as const }, - leaseMs: 1_000, - waitMs: 500, - processOwner, - }; - - await withOpenClawStateLease(options, async (owner) => { - runOpenClawStateWriteTransaction(({ db }) => { - executeSqliteQuerySync( - db, - getNodeSqliteKysely(db) - .updateTable("state_leases") - .set({ expires_at: Date.now() - 1 }) - .where("scope", "=", options.scope) - .where("lease_key", "=", options.key), - ); - }); - - await expect( - withOpenClawStateLease({ ...options, waitMs: 5 }, async () => undefined), - ).rejects.toMatchObject({ - code: "OPENCLAW_STATE_LEASE_TIMEOUT", - ownerPayload: { pid: process.pid }, - }); - expect(() => owner.assertOwned()).not.toThrow(); - }); - }); - }); - - it.each([ - { label: "dead", alive: false, observedStartTime: 123 }, - { label: "recycled", alive: true, observedStartTime: 456 }, - ])("reclaims a $label process-owned lease before its TTL", async (scenario) => { - await withOpenClawTestState( - { label: `core-state-process-lease-${scenario.label}` }, - async () => { - const now = Date.now(); - runOpenClawStateWriteTransaction(({ db }) => { - executeSqliteQuerySync( - db, - getNodeSqliteKysely(db) - .insertInto("state_leases") - .values({ - scope: "core:test", - lease_key: "stale-process-owner", - owner: "previous-owner", - expires_at: now + 300_000, - heartbeat_at: now, - payload_json: JSON.stringify({ pid: process.pid, starttime: 123 }), - created_at: now, - updated_at: now, - }), - ); - }); - - await withOpenClawStateLease( - { - scope: "core:test", - key: "stale-process-owner", - database: { scope: "shared" }, - leaseMs: 1_000, - waitMs: 0, - processOwner: { - pid: process.pid, - startTime: 456, - isAlive: () => scenario.alive, - readStartTime: () => scenario.observedStartTime, - }, - }, - async (lease) => lease.assertOwned(), - ); - }, - ); - }); }); diff --git a/src/state/openclaw-state-lease.ts b/src/state/openclaw-state-lease.ts index 2729100fca74..57966b7a7b27 100644 --- a/src/state/openclaw-state-lease.ts +++ b/src/state/openclaw-state-lease.ts @@ -21,16 +21,6 @@ import { runOpenClawStateWriteTransaction, type OpenClawStateDatabaseOptions, } from "./openclaw-state-db.js"; -import { - OpenClawStateLeaseError, - parseOpenClawStateLeaseOwnerPayload, - processLeaseIsReclaimable, - type OpenClawStateLeaseErrorCode, - type OpenClawStateLeaseOwnerPayload, - type OpenClawStateLeaseProcessOwner, -} from "./openclaw-state-lease-owner.js"; - -export { OpenClawStateLeaseError }; type LeaseDatabase = Pick; type AgentLeaseDatabase = Pick; @@ -51,10 +41,6 @@ type OpenClawStateLeaseOptions = { leaseLabel?: string; /** Stable transaction label used by SQLite diagnostics. */ operationLabel?: string; - /** Live process owners remain authoritative even when their advisory TTL expires. */ - processOwner?: OpenClawStateLeaseProcessOwner; - /** Propagate an exhausted release retry instead of silently relying on expiry. */ - strictRelease?: boolean; }; export type OpenClawStateLeaseContext = { @@ -65,11 +51,22 @@ export type OpenClawStateLeaseContext = { assertOwnedInTransaction(database: DatabaseSync): void; }; -export type OpenClawStateLeaseHandle = OpenClawStateLeaseContext & { - release(): Promise; - /** Release a manually held process lease during synchronous termination cleanup. */ - releaseSynchronously(): void; -}; +type OpenClawStateLeaseErrorCode = + | "OPENCLAW_STATE_LEASE_INVALID_INPUT" + | "OPENCLAW_STATE_LEASE_TIMEOUT" + | "OPENCLAW_STATE_LEASE_ABORTED" + | "OPENCLAW_STATE_LEASE_LOST" + | "OPENCLAW_STATE_LEASE_STORAGE_FAILED"; + +export class OpenClawStateLeaseError extends Error { + readonly code: OpenClawStateLeaseErrorCode; + + constructor(message: string, options: { code: OpenClawStateLeaseErrorCode; cause?: unknown }) { + super(message, { cause: options.cause }); + this.name = "OpenClawStateLeaseError"; + this.code = options.code; + } +} const ACQUIRE_BACKOFF = { initialMs: 25, @@ -124,12 +121,10 @@ function leaseError( code: OpenClawStateLeaseErrorCode, message: string, cause?: unknown, - ownerPayload?: OpenClawStateLeaseOwnerPayload, ): OpenClawStateLeaseError { return new OpenClawStateLeaseError(message, { code, ...(cause === undefined ? {} : { cause }), - ...(ownerPayload ? { ownerPayload } : {}), }); } @@ -190,8 +185,6 @@ function validateOptions(options: OpenClawStateLeaseOptions) { signal: options.signal, leaseLabel, operationLabel, - processOwner: options.processOwner, - strictRelease: options.strictRelease === true, }; } @@ -267,52 +260,27 @@ type LeaseIdentity = { key: string; owner: string; leaseLabel: string; - processOwner?: OpenClawStateLeaseProcessOwner; }; -type LeaseAcquireResult = - | { status: "acquired"; expiresAt: number } - | { status: "contended"; ownerPayload?: OpenClawStateLeaseOwnerPayload }; - function tryAcquire( params: LeaseIdentity & { database: OpenClawStateLeaseDatabase; operationLabel: string; leaseMs: number; }, -): LeaseAcquireResult { - const observed = params.processOwner - ? withLeaseRead(params.database, (db, kysely) => - executeSqliteQueryTakeFirstSync( - db, - kysely - .selectFrom("state_leases") - .select(["owner", "expires_at", "payload_json"]) - .where("scope", "=", params.scope) - .where("lease_key", "=", params.key), - ), - ) - : undefined; - // Process inspection may perform filesystem/subprocess work, so gather it - // before BEGIN and fence deletion against the observed owner inside it. - const reclaimableOwner = - observed && params.processOwner && processLeaseIsReclaimable(observed, params.processOwner) - ? observed.owner - : undefined; +): number | undefined { return withLeaseWriteTransaction(params.database, params.operationLabel, (db, kysely) => { // BEGIN IMMEDIATE may wait on SQLite. Sample only after admission so a // successful insert never commits an already-expired lease. const now = Date.now(); - if (!params.processOwner || reclaimableOwner) { - let stale = kysely + executeSqliteQuerySync( + db, + kysely .deleteFrom("state_leases") .where("scope", "=", params.scope) - .where("lease_key", "=", params.key); - stale = reclaimableOwner - ? stale.where("owner", "=", reclaimableOwner) - : stale.where("expires_at", "<=", now); - executeSqliteQuerySync(db, stale); - } + .where("lease_key", "=", params.key) + .where("expires_at", "<=", now), + ); const expiresAt = now + params.leaseMs; const inserted = executeSqliteQuerySync( db, @@ -324,35 +292,13 @@ function tryAcquire( owner: params.owner, expires_at: expiresAt, heartbeat_at: now, - payload_json: params.processOwner - ? JSON.stringify({ - pid: params.processOwner.pid, - ...(params.processOwner.startTime === null - ? {} - : { starttime: params.processOwner.startTime }), - }) - : null, + payload_json: null, created_at: now, updated_at: now, }) .onConflict((conflict) => conflict.columns(["scope", "lease_key"]).doNothing()), ); - if (inserted.numAffectedRows === 1n) { - return { status: "acquired", expiresAt }; - } - const contender = executeSqliteQueryTakeFirstSync( - db, - kysely - .selectFrom("state_leases") - .select("payload_json") - .where("scope", "=", params.scope) - .where("lease_key", "=", params.key), - ); - const ownerPayload = parseOpenClawStateLeaseOwnerPayload(contender?.payload_json ?? null); - return { - status: "contended", - ...(ownerPayload ? { ownerPayload } : {}), - }; + return inserted.numAffectedRows === 1n ? expiresAt : undefined; }); } @@ -366,16 +312,16 @@ function renew( return withLeaseWriteTransaction(params.database, params.operationLabel, (db, kysely) => { const now = Date.now(); const expiresAt = now + params.leaseMs; - let update = kysely - .updateTable("state_leases") - .set({ expires_at: expiresAt, heartbeat_at: now, updated_at: now }) - .where("scope", "=", params.scope) - .where("lease_key", "=", params.key) - .where("owner", "=", params.owner); - if (!params.processOwner) { - update = update.where("expires_at", ">", now); - } - const updated = executeSqliteQuerySync(db, update); + const updated = executeSqliteQuerySync( + db, + kysely + .updateTable("state_leases") + .set({ expires_at: expiresAt, heartbeat_at: now, updated_at: now }) + .where("scope", "=", params.scope) + .where("lease_key", "=", params.key) + .where("owner", "=", params.owner) + .where("expires_at", ">", now), + ); if (updated.numAffectedRows !== 1n) { throw leaseError( "OPENCLAW_STATE_LEASE_LOST", @@ -392,16 +338,16 @@ function assertLeaseOwnedInDatabase( params: LeaseIdentity, ): void { const now = Date.now(); - let query = kysely - .selectFrom("state_leases") - .select("owner") - .where("scope", "=", params.scope) - .where("lease_key", "=", params.key) - .where("owner", "=", params.owner); - if (!params.processOwner) { - query = query.where("expires_at", ">", now); - } - const row = executeSqliteQueryTakeFirstSync(database, query); + const row = executeSqliteQueryTakeFirstSync( + database, + kysely + .selectFrom("state_leases") + .select("owner") + .where("scope", "=", params.scope) + .where("lease_key", "=", params.key) + .where("owner", "=", params.owner) + .where("expires_at", ">", now), + ); if (!row) { throw leaseError( "OPENCLAW_STATE_LEASE_LOST", @@ -456,10 +402,7 @@ function release( }); } -async function releaseBestEffort( - params: Parameters[0], - strict = false, -): Promise { +async function releaseBestEffort(params: Parameters[0]): Promise { const deadline = performance.now() + RELEASE_RETRY_TIMEOUT_MS; let attempt = 0; while (true) { @@ -469,9 +412,6 @@ async function releaseBestEffort( } catch (error) { const now = performance.now(); if (!isSqliteLockError(error) || now >= deadline) { - if (strict) { - throw error; - } return; } attempt += 1; @@ -494,10 +434,11 @@ function abortError( ); } -/** Acquire a host-owned SQLite lease for callers whose lifecycle spans callbacks. */ -export async function acquireOpenClawStateLease( +/** Run one trusted operation under a host-owned SQLite lease. */ +export async function withOpenClawStateLease( options: OpenClawStateLeaseOptions, -): Promise { + run: (lease: OpenClawStateLeaseContext) => Promise, +): Promise { const validated = validateOptions(options); if (validated.signal?.aborted) { throw abortError(validated.signal, "acquisition", validated.leaseLabel); @@ -508,13 +449,12 @@ export async function acquireOpenClawStateLease( const deadline = performance.now() + validated.waitMs; let attempt = 0; let confirmedExpiresAt: number | undefined; - let lastObservedOwnerPayload: OpenClawStateLeaseOwnerPayload | undefined; while (confirmedExpiresAt === undefined) { if (validated.signal?.aborted) { throw abortError(validated.signal, "acquisition", validated.leaseLabel); } try { - const result = tryAcquire({ + confirmedExpiresAt = tryAcquire({ database: validated.database, operationLabel: validated.operationLabel, scope: validated.scope, @@ -522,14 +462,7 @@ export async function acquireOpenClawStateLease( owner, leaseMs: validated.leaseMs, leaseLabel: validated.leaseLabel, - processOwner: validated.processOwner, }); - if (result.status === "acquired") { - confirmedExpiresAt = result.expiresAt; - lastObservedOwnerPayload = undefined; - } else { - lastObservedOwnerPayload = result.ownerPayload; - } } catch (error) { if (error instanceof OpenClawStateLeaseError) { throw error; @@ -559,8 +492,6 @@ export async function acquireOpenClawStateLease( throw leaseError( "OPENCLAW_STATE_LEASE_TIMEOUT", `timed out waiting for ${validated.leaseLabel} ${validated.scope}/${validated.key}`, - undefined, - lastObservedOwnerPayload, ); } break; @@ -569,8 +500,6 @@ export async function acquireOpenClawStateLease( throw leaseError( "OPENCLAW_STATE_LEASE_TIMEOUT", `timed out waiting for ${validated.leaseLabel} ${validated.scope}/${validated.key}`, - undefined, - lastObservedOwnerPayload, ); } attempt += 1; @@ -590,7 +519,6 @@ export async function acquireOpenClawStateLease( key: validated.key, owner, leaseLabel: validated.leaseLabel, - processOwner: validated.processOwner, }; // `process.exit()` skips async `finally` blocks. Release synchronously so a normal CLI error // cannot strand the lease until its TTL and block the next lifecycle command. @@ -630,9 +558,7 @@ export async function acquireOpenClawStateLease( ); expiryTimer.unref?.(); }; - if (!validated.processOwner) { - scheduleExpiry(); - } + scheduleExpiry(); const heartbeat = setInterval(() => { try { confirmedExpiresAt = renew({ @@ -641,17 +567,11 @@ export async function acquireOpenClawStateLease( operationLabel: validated.operationLabel, leaseMs: validated.leaseMs, }); - if (!validated.processOwner) { - scheduleExpiry(); - } + scheduleExpiry(); } catch (error) { if (error instanceof OpenClawStateLeaseError && error.code === "OPENCLAW_STATE_LEASE_LOST") { abortLost(error); - } else if ( - !validated.processOwner && - confirmedExpiresAt !== undefined && - Date.now() >= confirmedExpiresAt - ) { + } else if (confirmedExpiresAt !== undefined && Date.now() >= confirmedExpiresAt) { abortLost(error); } } @@ -676,74 +596,45 @@ export async function acquireOpenClawStateLease( } verifyLeaseOwnership({ ...identity, transaction: database }); }; - let released = false; - let releasePromise: Promise | undefined; - const clearLeaseLifecycle = () => { + + try { + let result: T; + try { + // Acquisition and callback entry are separate scheduling points. A + // suspended process must not enter after its persisted lease expires. + assertOperationOwned(); + result = await run({ + signal: operationSignal, + assertOwned: assertOperationOwned, + assertOwnedInTransaction: assertOperationOwnedInTransaction, + }); + } catch (error) { + if (leaseLost.signal.aborted) { + throw leaseLost.signal.reason; + } + if (validated.signal?.aborted) { + throw abortError(validated.signal, "operation", validated.leaseLabel); + } + throw error; + } + if (leaseLost.signal.aborted) { + throw leaseLost.signal.reason; + } + if (validated.signal?.aborted) { + throw abortError(validated.signal, "operation", validated.leaseLabel); + } + verifyLeaseOwnership({ ...identity, database: validated.database }); + return result; + } finally { unregisterProcessExitCleanup(); clearInterval(heartbeat); if (expiryTimer) { clearTimeout(expiryTimer); } - }; - const releaseSynchronously = () => { - if (released) { - return; - } - release({ + await releaseBestEffort({ ...identity, database: validated.database, operationLabel: validated.operationLabel, }); - released = true; - clearLeaseLifecycle(); - }; - return { - signal: operationSignal, - assertOwned: assertOperationOwned, - assertOwnedInTransaction: assertOperationOwnedInTransaction, - releaseSynchronously, - release: () => { - if (released) { - return Promise.resolve(); - } - releasePromise ??= releaseBestEffort( - { ...identity, database: validated.database, operationLabel: validated.operationLabel }, - validated.strictRelease, - ).then( - () => { - released = true; - clearLeaseLifecycle(); - }, - (error: unknown) => { - releasePromise = undefined; - throw error; - }, - ); - return releasePromise; - }, - }; -} - -/** Run one trusted operation under a host-owned SQLite lease. */ -export async function withOpenClawStateLease( - options: OpenClawStateLeaseOptions, - run: (lease: OpenClawStateLeaseContext) => Promise, -): Promise { - const lease = await acquireOpenClawStateLease(options); - try { - lease.assertOwned(); - const result = await run(lease); - lease.assertOwned(); - return result; - } catch (error) { - if (options.signal?.aborted) { - throw abortError(options.signal, "operation", options.leaseLabel ?? "state lease"); - } - if (lease.signal.aborted) { - throw lease.signal.reason; - } - throw error; - } finally { - await lease.release(); } } diff --git a/src/test-utils/session-state-cleanup.test.ts b/src/test-utils/session-state-cleanup.test.ts index b57a7b1417ee..29e96d750251 100644 --- a/src/test-utils/session-state-cleanup.test.ts +++ b/src/test-utils/session-state-cleanup.test.ts @@ -4,7 +4,6 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../test/helpers/promise.js"; -import { resetSessionWriteLockStateForTest } from "../agents/session-write-lock.test-support.js"; import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js"; import { runExclusiveSessionStoreWrite } from "../config/sessions/store-writer.js"; import { resetFileLockStateForTest } from "../infra/file-lock.js"; @@ -16,7 +15,6 @@ import { const drainFileLockStateMock = vi.hoisted(() => vi.fn(async () => undefined)); const drainSessionStoreWriterQueuesMock = vi.hoisted(() => vi.fn(async () => undefined)); -const drainSessionWriteLockStateMock = vi.hoisted(() => vi.fn(async () => undefined)); async function flushMicrotasks(rounds = 3): Promise { for (let index = 0; index < rounds; index += 1) { @@ -29,14 +27,11 @@ describe("cleanupSessionStateForTest", () => { vi.useRealTimers(); clearSessionStoreCacheForTest(); resetFileLockStateForTest(); - resetSessionWriteLockStateForTest(); drainFileLockStateMock.mockClear(); drainSessionStoreWriterQueuesMock.mockClear(); - drainSessionWriteLockStateMock.mockClear(); setSessionStateCleanupRuntimeForTests({ drainFileLockStateForTest: drainFileLockStateMock, drainSessionStoreWriterQueuesForTest: drainSessionStoreWriterQueuesMock, - drainSessionWriteLockStateForTest: drainSessionWriteLockStateMock, }); }); @@ -44,7 +39,6 @@ describe("cleanupSessionStateForTest", () => { vi.useRealTimers(); clearSessionStoreCacheForTest(); resetFileLockStateForTest(); - resetSessionWriteLockStateForTest(); resetSessionStateCleanupRuntimeForTests(); vi.restoreAllMocks(); }); @@ -81,7 +75,6 @@ describe("cleanupSessionStateForTest", () => { expect(settled).toBe(false); expect(drainSessionStoreWriterQueuesMock).toHaveBeenCalledTimes(1); expect(drainFileLockStateMock).not.toHaveBeenCalled(); - expect(drainSessionWriteLockStateMock).not.toHaveBeenCalled(); release.resolve(); await running; @@ -89,7 +82,6 @@ describe("cleanupSessionStateForTest", () => { await cleanupPromise; expect(drainFileLockStateMock).toHaveBeenCalledTimes(1); - expect(drainSessionWriteLockStateMock).toHaveBeenCalledTimes(1); } finally { release.resolve(); finishDrain(); diff --git a/src/test-utils/session-state-cleanup.ts b/src/test-utils/session-state-cleanup.ts index 245564c2f6e1..82ef92bbc0d7 100644 --- a/src/test-utils/session-state-cleanup.ts +++ b/src/test-utils/session-state-cleanup.ts @@ -1,5 +1,4 @@ // Cleans session-related shared state after tests. -import { drainSessionWriteLockStateForTest } from "../agents/session-write-lock.js"; import { clearSessionStoreCacheForTest, drainSessionStoreWriterQueuesForTest, @@ -9,13 +8,11 @@ import { drainFileLockStateForTest } from "../infra/file-lock.js"; let fileLockDrainerForTests: typeof drainFileLockStateForTest | null = null; let sessionStoreWriterQueueDrainerForTests: typeof drainSessionStoreWriterQueuesForTest | null = null; -let sessionWriteLockDrainerForTests: typeof drainSessionWriteLockStateForTest | null = null; /** Overrides cleanup hooks so tests can drain mocked session state modules. */ export function setSessionStateCleanupRuntimeForTests(params: { drainFileLockStateForTest?: typeof drainFileLockStateForTest | null; drainSessionStoreWriterQueuesForTest?: typeof drainSessionStoreWriterQueuesForTest | null; - drainSessionWriteLockStateForTest?: typeof drainSessionWriteLockStateForTest | null; }): void { if ("drainFileLockStateForTest" in params) { fileLockDrainerForTests = params.drainFileLockStateForTest ?? null; @@ -23,20 +20,15 @@ export function setSessionStateCleanupRuntimeForTests(params: { if ("drainSessionStoreWriterQueuesForTest" in params) { sessionStoreWriterQueueDrainerForTests = params.drainSessionStoreWriterQueuesForTest ?? null; } - if ("drainSessionWriteLockStateForTest" in params) { - sessionWriteLockDrainerForTests = params.drainSessionWriteLockStateForTest ?? null; - } } export function resetSessionStateCleanupRuntimeForTests(): void { fileLockDrainerForTests = null; sessionStoreWriterQueueDrainerForTests = null; - sessionWriteLockDrainerForTests = null; } export async function cleanupSessionStateForTest(): Promise { await (sessionStoreWriterQueueDrainerForTests ?? drainSessionStoreWriterQueuesForTest)(); clearSessionStoreCacheForTest(); await (fileLockDrainerForTests ?? drainFileLockStateForTest)(); - await (sessionWriteLockDrainerForTests ?? drainSessionWriteLockStateForTest)(); } diff --git a/src/worker/embedded-agent-transcript.runtime.ts b/src/worker/embedded-agent-transcript.runtime.ts index 830412f4ef85..db3d1d45bda5 100644 --- a/src/worker/embedded-agent-transcript.runtime.ts +++ b/src/worker/embedded-agent-transcript.runtime.ts @@ -3,7 +3,7 @@ import { WORKER_TRANSCRIPT_MAX_BATCH_MESSAGES } from "../../packages/gateway-pro import type { WorkerInferenceContext } from "../../packages/gateway-protocol/src/schema/worker-inference.js"; import { WORKER_INFERENCE_MAX_CONTEXT_MESSAGES } from "../../packages/gateway-protocol/src/schema/worker-inference.js"; import type { AgentMessage } from "../agents/runtime/index.js"; -import type { AgentSessionWriteLockRunner } from "../agents/sessions/agent-session.js"; +import type { AgentSessionWriteSettlementRunner } from "../agents/sessions/agent-session.js"; import type { Context, Message } from "../llm/types.js"; import { windowWorkerReplayMessages, @@ -117,7 +117,7 @@ type WorkerTranscriptClient = { type WorkerTranscriptRuntime = { onMessagePersisted: (message: AgentMessage) => void; - withSessionWriteLock: AgentSessionWriteLockRunner; + withSessionWriteSettlement: AgentSessionWriteSettlementRunner; }; export function createWorkerTranscriptRuntime( @@ -147,7 +147,7 @@ export function createWorkerTranscriptRuntime( } }; let sessionWriteQueue: Promise = Promise.resolve(); - const withSessionWriteLock: AgentSessionWriteLockRunner = ( + const withSessionWriteSettlement: AgentSessionWriteSettlementRunner = ( operation: () => Promise | T, ): Promise => { const result = sessionWriteQueue.then(async () => { @@ -161,5 +161,5 @@ export function createWorkerTranscriptRuntime( ); return result; }; - return { onMessagePersisted, withSessionWriteLock }; + return { onMessagePersisted, withSessionWriteSettlement }; } diff --git a/src/worker/embedded-agent.runtime.ts b/src/worker/embedded-agent.runtime.ts index 9adfc696b3a3..c547caf1c642 100644 --- a/src/worker/embedded-agent.runtime.ts +++ b/src/worker/embedded-agent.runtime.ts @@ -193,7 +193,7 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams) sessionManager, settingsManager, resourceLoader, - withSessionWriteLock: transcriptRuntime.withSessionWriteLock, + withSessionWriteSettlement: transcriptRuntime.withSessionWriteSettlement, }); session.agent.sessionId = params.sessionId; session.setActiveToolsByName([...activeToolNames]); @@ -251,7 +251,7 @@ export async function runWorkerEmbeddedTurn(params: RunWorkerEmbeddedTurnParams) let finalTranscriptFailure: Error | undefined; try { try { - await transcriptRuntime.withSessionWriteLock(() => undefined); + await transcriptRuntime.withSessionWriteSettlement(() => undefined); } catch (error) { finalTranscriptFailure = toError(error, "Worker transcript flush failed."); } diff --git a/test/setup-openclaw-runtime.ts b/test/setup-openclaw-runtime.ts index 38cf81f51f93..edd09557ca54 100644 --- a/test/setup-openclaw-runtime.ts +++ b/test/setup-openclaw-runtime.ts @@ -28,12 +28,10 @@ type WorkerCleanupHelpers = { clearSessionStoreCacheForTest: typeof import("../src/config/sessions/store-writer-state.js").clearSessionStoreCacheForTest; drainFileLockStateForTest: typeof import("../src/infra/file-lock.js").drainFileLockStateForTest; drainSessionStoreWriterQueuesForTest: typeof import("../src/config/sessions/store-writer-state.js").drainSessionStoreWriterQueuesForTest; - drainSessionWriteLockStateForTest: typeof import("../src/agents/session-write-lock.js").drainSessionWriteLockStateForTest; resetContextWindowCacheForTest: typeof import("../src/agents/context-runtime-state.js").resetContextWindowCacheForTest; resetFileLockStateForTest: typeof import("../src/infra/file-lock.js").resetFileLockStateForTest; resetModelsJsonReadyCacheForTest: typeof import("../src/agents/models-config-state.test-support.js").resetModelsJsonReadyCacheForTest; resetPreparedModelRuntimeSnapshotsForTest: typeof import("../src/agents/prepared-model-runtime.test-support.js").resetPreparedModelRuntimeSnapshotsForTest; - resetSessionWriteLockStateForTest: typeof import("../src/agents/session-write-lock.test-support.js").resetSessionWriteLockStateForTest; }; type ReplyToModeResolver = NonNullable< @@ -71,16 +69,10 @@ function loadWorkerCleanupHelpers(): Promise { [WORKER_CLEANUP_HELPERS]?: Promise; }; globalState[WORKER_CLEANUP_HELPERS] ??= (async () => { - // The test-support facade reads the API installed by the real lock module at import time. - // Load that producer first so concurrent module evaluation cannot observe a missing API. - const sessionWriteLock = await vi.importActual< - typeof import("../src/agents/session-write-lock.js") - >("../src/agents/session-write-lock.js"); const [ contextRuntimeState, modelsConfigState, preparedModelRuntime, - sessionWriteLockTestSupport, sessionStoreWriterState, fileLock, ] = await Promise.all([ @@ -93,9 +85,6 @@ function loadWorkerCleanupHelpers(): Promise { vi.importActual( "../src/agents/prepared-model-runtime.test-support.js", ), - vi.importActual( - "../src/agents/session-write-lock.test-support.js", - ), vi.importActual( "../src/config/sessions/store-writer-state.js", ), @@ -106,14 +95,11 @@ function loadWorkerCleanupHelpers(): Promise { drainFileLockStateForTest: fileLock.drainFileLockStateForTest, drainSessionStoreWriterQueuesForTest: sessionStoreWriterState.drainSessionStoreWriterQueuesForTest, - drainSessionWriteLockStateForTest: sessionWriteLock.drainSessionWriteLockStateForTest, resetContextWindowCacheForTest: contextRuntimeState.resetContextWindowCacheForTest, resetFileLockStateForTest: fileLock.resetFileLockStateForTest, resetModelsJsonReadyCacheForTest: modelsConfigState.resetModelsJsonReadyCacheForTest, resetPreparedModelRuntimeSnapshotsForTest: preparedModelRuntime.resetPreparedModelRuntimeSnapshotsForTest, - resetSessionWriteLockStateForTest: - sessionWriteLockTestSupport.resetSessionWriteLockStateForTest, }; })(); return globalState[WORKER_CLEANUP_HELPERS]; @@ -383,32 +369,24 @@ afterEach(async () => { clearSessionStoreCacheForTest, drainFileLockStateForTest, drainSessionStoreWriterQueuesForTest, - drainSessionWriteLockStateForTest, resetContextWindowCacheForTest, resetFileLockStateForTest, resetModelsJsonReadyCacheForTest, resetPreparedModelRuntimeSnapshotsForTest, - resetSessionWriteLockStateForTest, } = await loadWorkerCleanupHelpers(); await drainSessionStoreWriterQueuesForTest(); clearSessionStoreCacheForTest(); await drainFileLockStateForTest(); - await drainSessionWriteLockStateForTest(); resetFileLockStateForTest(); resetContextWindowCacheForTest(); resetModelsJsonReadyCacheForTest(); resetPreparedModelRuntimeSnapshotsForTest(); - resetSessionWriteLockStateForTest(); await installDefaultPluginRegistry(); }); afterAll(async () => { - const { - clearSessionStoreCacheForTest, - drainFileLockStateForTest, - drainSessionWriteLockStateForTest, - } = await loadWorkerCleanupHelpers(); + const { clearSessionStoreCacheForTest, drainFileLockStateForTest } = + await loadWorkerCleanupHelpers(); clearSessionStoreCacheForTest(); await drainFileLockStateForTest(); - await drainSessionWriteLockStateForTest(); }); diff --git a/test/vitest/vitest.unit-fast-paths.mjs b/test/vitest/vitest.unit-fast-paths.mjs index 5feae695eb0a..8b48ae1f33f5 100644 --- a/test/vitest/vitest.unit-fast-paths.mjs +++ b/test/vitest/vitest.unit-fast-paths.mjs @@ -166,6 +166,7 @@ const broadUnitFastCandidateGlobs = [ "test/**/*.test.ts", ]; const ownerRoutedUnitTestPatterns = [ + "src/agents/embedded-agent-runner/run/attempt.abort-race.test.ts", "src/agents/openai-transport-stream.*.test.ts", "src/agents/embedded-agent-runner/run.shared-integration.test.ts", "src/auto-reply/reply/dispatch-from-config.test.ts",