authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-07 10:51:10-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 10:51:10-07:00
logf570bd21663f56ce94c41829d3b7d72886e25a34
treeddcbebf0eab2657a2377c6a3860266be5d97c158
parent41985b44ff1b3a353ab805a31d4e4810842cbf2b
signature Signed by PGP key B5690EEEBB952194

fix(server): Claude prompts no longer hang on a uuid the session already holds (#16897)

Co-authored-by: saphid <saphid@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 220 insertions(+), 80 deletions(-)

apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+163-54
...@@ -2229,6 +2229,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2229,6 +2229,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2229 processQueues,2229 processQueues,
2230 offerAndWait,2230 offerAndWait,
2231 offeredMessages,2231 offeredMessages,
2232 // The uuid Claude echoes on the turn answering the nth offered prompt.
2233 promptUuid: (index: number) => {
2234 const uuid = offeredMessages[index]?.uuid;
2235 if (uuid === undefined) throw new Error(`No prompt offered at index ${index}.`);
2236 return uuid;
2237 },
2232 permissionModeChanges,2238 permissionModeChanges,
2233 continuationRequests,2239 continuationRequests,
2234 events,2240 events,
...@@ -4421,12 +4427,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4421,12 +4427,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4421 const continuationAttempt = RunAttemptId.make("attempt-claude-wake-callback-3");4427 const continuationAttempt = RunAttemptId.make("attempt-claude-wake-callback-3");
4422 const planToolUseId = "toolu_01WakePlanExitPlanMode";4428 const planToolUseId = "toolu_01WakePlanExitPlanMode";
4423 const planMarkdown = "# Wake plan\n\n1. Report the background result.";4429 const planMarkdown = "# Wake plan\n\n1. Report the background result.";
4424 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {4430 const stamp = (frame: SDKMessage, promptIndex: number) =>
4425 return claudeSdkFrame({4431 claudeSdkFrame({
4426 ...frame,4432 ...frame,
4427 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),4433 user_message_uuid: harness.promptUuid(promptIndex),
4428 });4434 });
4429 });
4430 const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`);4435 const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`);
4431 const planToolUse = claudeSdkFrame({4436 const planToolUse = claudeSdkFrame({
4432 type: "assistant",4437 type: "assistant",
...@@ -4479,12 +4484,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4479,12 +4484,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4479 runtimePolicy,4484 runtimePolicy,
4480 }),4485 }),
4481 );4486 );
4482 yield* Queue.offer(harness.sdkMessages, yield* stamp(wakeTaskStarted, firstAttempt));4487 yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, 0));
4483 yield* Queue.offer(4488 yield* Queue.offer(
4484 harness.sdkMessages,4489 harness.sdkMessages,
4485 yield* stamp(4490 stamp(
4486 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000740", result: "STARTED" }),4491 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000740", result: "STARTED" }),
4487 firstAttempt,4492 0,
4488 ),4493 ),
4489 );4494 );
4490 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");4495 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
...@@ -4541,22 +4546,22 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4541,22 +4546,22 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4541 // The prompt's own turn follows and echoes its uuid.4546 // The prompt's own turn follows and echoes its uuid.
4542 yield* Queue.offer(4547 yield* Queue.offer(
4543 harness.sdkMessages,4548 harness.sdkMessages,
4544 yield* stamp(4549 stamp(
4545 makeAssistantTextFrame({4550 makeAssistantTextFrame({
4546 uuid: "00000000-0000-4000-8000-000000000743",4551 uuid: "00000000-0000-4000-8000-000000000743",
4547 text: "USER_REPLY",4552 text: "USER_REPLY",
4548 }),4553 }),
4549 userAttempt,4554 1,
4550 ),4555 ),
4551 );4556 );
4552 yield* Queue.offer(4557 yield* Queue.offer(
4553 harness.sdkMessages,4558 harness.sdkMessages,
4554 yield* stamp(4559 stamp(
4555 makeResultFrame({4560 makeResultFrame({
4556 uuid: "00000000-0000-4000-8000-000000000744",4561 uuid: "00000000-0000-4000-8000-000000000744",
4557 result: "USER_REPLY",4562 result: "USER_REPLY",
4558 }),4563 }),
4559 userAttempt,4564 1,
4560 ),4565 ),
4561 );4566 );
4562 yield* awaitUntil(() => harness.terminalEvents().length === 2, "user turn terminal");4567 yield* awaitUntil(() => harness.terminalEvents().length === 2, "user turn terminal");
...@@ -4721,12 +4726,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4721,12 +4726,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4721 const firstAttempt = RunAttemptId.make("attempt-claude-wake-approval-1");4726 const firstAttempt = RunAttemptId.make("attempt-claude-wake-approval-1");
4722 const userAttempt = RunAttemptId.make("attempt-claude-wake-approval-2");4727 const userAttempt = RunAttemptId.make("attempt-claude-wake-approval-2");
4723 const bashToolUseId = "toolu_01WakeApprovalBash";4728 const bashToolUseId = "toolu_01WakeApprovalBash";
4724 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {4729 const stamp = (frame: SDKMessage, promptIndex: number) =>
4725 return claudeSdkFrame({4730 claudeSdkFrame({
4726 ...frame,4731 ...frame,
4727 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),4732 user_message_uuid: harness.promptUuid(promptIndex),
4728 });4733 });
4729 });
47304734
4731 yield* harness.runtime.startTurn(4735 yield* harness.runtime.startTurn(
4732 makeClaudeTestTurnInput({4736 makeClaudeTestTurnInput({
...@@ -4741,16 +4745,16 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4741,16 +4745,16 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4741 );4745 );
4742 yield* Queue.offer(4746 yield* Queue.offer(
4743 harness.sdkMessages,4747 harness.sdkMessages,
4744 yield* stamp(4748 stamp(
4745 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000780", text: "One." }),4749 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000780", text: "One." }),
4746 firstAttempt,4750 0,
4747 ),4751 ),
4748 );4752 );
4749 yield* Queue.offer(4753 yield* Queue.offer(
4750 harness.sdkMessages,4754 harness.sdkMessages,
4751 yield* stamp(4755 stamp(
4752 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000781", result: "One." }),4756 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000781", result: "One." }),
4753 firstAttempt,4757 0,
4754 ),4758 ),
4755 );4759 );
4756 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");4760 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
...@@ -4845,12 +4849,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4845,12 +4849,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4845 const now = yield* DateTime.now;4849 const now = yield* DateTime.now;
4846 const firstAttempt = RunAttemptId.make("attempt-claude-echo-debris-1");4850 const firstAttempt = RunAttemptId.make("attempt-claude-echo-debris-1");
4847 const secondAttempt = RunAttemptId.make("attempt-claude-echo-debris-2");4851 const secondAttempt = RunAttemptId.make("attempt-claude-echo-debris-2");
4848 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {4852 const stamp = (frame: SDKMessage, promptIndex: number) =>
4849 return claudeSdkFrame({4853 claudeSdkFrame({
4850 ...frame,4854 ...frame,
4851 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),4855 user_message_uuid: harness.promptUuid(promptIndex),
4852 });4856 });
4853 });
48544857
4855 yield* harness.runtime.startTurn(4858 yield* harness.runtime.startTurn(
4856 makeClaudeTestTurnInput({4859 makeClaudeTestTurnInput({
...@@ -4865,16 +4868,16 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4865,16 +4868,16 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4865 // The first turn echoes on its first frame: this process echoes early.4868 // The first turn echoes on its first frame: this process echoes early.
4866 yield* Queue.offer(4869 yield* Queue.offer(
4867 harness.sdkMessages,4870 harness.sdkMessages,
4868 yield* stamp(4871 stamp(
4869 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000760", text: "One." }),4872 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000760", text: "One." }),
4870 firstAttempt,4873 0,
4871 ),4874 ),
4872 );4875 );
4873 yield* Queue.offer(4876 yield* Queue.offer(
4874 harness.sdkMessages,4877 harness.sdkMessages,
4875 yield* stamp(4878 stamp(
4876 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000761", result: "One." }),4879 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000761", result: "One." }),
4877 firstAttempt,4880 0,
4878 ),4881 ),
4879 );4882 );
4880 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");4883 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
...@@ -4899,16 +4902,16 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4899,16 +4902,16 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4899 yield* Queue.offer(harness.sdkMessages, staleTaskNotificationResult);4902 yield* Queue.offer(harness.sdkMessages, staleTaskNotificationResult);
4900 yield* Queue.offer(4903 yield* Queue.offer(
4901 harness.sdkMessages,4904 harness.sdkMessages,
4902 yield* stamp(4905 stamp(
4903 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000762", text: "Two." }),4906 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000762", text: "Two." }),
4904 secondAttempt,4907 1,
4905 ),4908 ),
4906 );4909 );
4907 yield* Queue.offer(4910 yield* Queue.offer(
4908 harness.sdkMessages,4911 harness.sdkMessages,
4909 yield* stamp(4912 stamp(
4910 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000763", result: "Two." }),4913 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000763", result: "Two." }),
4911 secondAttempt,4914 1,
4912 ),4915 ),
4913 );4916 );
4914 yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal");4917 yield* awaitUntil(() => harness.terminalEvents().length === 2, "second turn terminal");
...@@ -4944,12 +4947,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4944,12 +4947,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4944 const now = yield* DateTime.now;4947 const now = yield* DateTime.now;
4945 const firstAttempt = RunAttemptId.make("attempt-claude-echo-1");4948 const firstAttempt = RunAttemptId.make("attempt-claude-echo-1");
4946 const secondAttempt = RunAttemptId.make("attempt-claude-echo-2");4949 const secondAttempt = RunAttemptId.make("attempt-claude-echo-2");
4947 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {4950 const stamp = (frame: SDKMessage, promptIndex: number) =>
4948 return claudeSdkFrame({4951 claudeSdkFrame({
4949 ...frame,4952 ...frame,
4950 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),4953 user_message_uuid: harness.promptUuid(promptIndex),
4951 });4954 });
4952 });
4953 const assistantTexts = () =>4955 const assistantTexts = () =>
4954 harness.events.flatMap((event) =>4956 harness.events.flatMap((event) =>
4955 event.type === "message.updated" && event.message.role === "assistant"4957 event.type === "message.updated" && event.message.role === "assistant"
...@@ -4969,22 +4971,18 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4969,22 +4971,18 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4969 attachments: [],4971 attachments: [],
4970 }),4972 }),
4971 );4973 );
4972 assert.equal(
4973 harness.offeredMessages[0]?.uuid,
4974 yield* ClaudeAdapterV2.claudePromptUuid(firstAttempt),
4975 );
4976 yield* Queue.offer(4974 yield* Queue.offer(
4977 harness.sdkMessages,4975 harness.sdkMessages,
4978 yield* stamp(4976 stamp(
4979 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000701", text: "One." }),4977 makeAssistantTextFrame({ uuid: "00000000-0000-4000-8000-000000000701", text: "One." }),
4980 firstAttempt,4978 0,
4981 ),4979 ),
4982 );4980 );
4983 yield* Queue.offer(4981 yield* Queue.offer(
4984 harness.sdkMessages,4982 harness.sdkMessages,
4985 yield* stamp(4983 stamp(
4986 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000702", result: "One." }),4984 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000702", result: "One." }),
4987 firstAttempt,4985 0,
4988 ),4986 ),
4989 );4987 );
4990 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");4988 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
...@@ -5018,6 +5016,118 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -5018,6 +5016,118 @@ describe("ClaudeAdapterV2 background wake turns", () => {
5018 ),5016 ),
5019 );5017 );
50205018
5019 it.effect("gives the same run attempt a fresh prompt uuid on every offer", () =>
5020 Effect.scoped(
5021 Effect.gen(function* () {
5022 // Two servers sharing a database copy allocate identical run attempt
5023 // ids and resume the same Claude session. Claude acks a prompt whose
5024 // uuid its transcript already holds without ever running a turn.
5025 const original = yield* makeWakeHarness;
5026 const copy = yield* makeWakeHarness;
5027 const now = yield* DateTime.now;
5028 for (const harness of [original, copy]) {
5029 yield* harness.runtime.startTurn(
5030 makeClaudeTestTurnInput({
5031 threadId: harness.threadId,
5032 providerThread: harness.providerThread,
5033 now,
5034 attemptId: RunAttemptId.make("attempt-claude-shared-copy"),
5035 text: "Continue where you left off.",
5036 attachments: [],
5037 }),
5038 );
5039 }
5040 assert.notEqual(original.promptUuid(0), copy.promptUuid(0));
5041 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
5042 ),
5043 );
5044
5045 it.effect("fails a prompt Claude completes without starting a turn for it", () =>
5046 Effect.scoped(
5047 Effect.gen(function* () {
5048 // Claude answers a prompt whose uuid its transcript already holds with
5049 // a lone completed lifecycle frame and never runs a turn for it.
5050 const harness = yield* makeWakeHarness;
5051 const now = yield* DateTime.now;
5052 yield* harness.runtime.startTurn(
5053 makeClaudeTestTurnInput({
5054 threadId: harness.threadId,
5055 providerThread: harness.providerThread,
5056 now,
5057 attemptId: RunAttemptId.make("attempt-claude-duplicate-prompt"),
5058 text: "Bump this PR to the latest main.",
5059 attachments: [],
5060 }),
5061 );
5062 yield* Queue.offer(
5063 harness.sdkMessages,
5064 claudeSdkFrame({
5065 type: "command_lifecycle",
5066 command_uuid: harness.promptUuid(0),
5067 state: "completed",
5068 uuid: "00000000-0000-4000-8000-000000000790",
5069 session_id: WAKE_NATIVE_SESSION,
5070 }),
5071 );
5072 yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal");
5073 const [terminal] = harness.terminalEvents();
5074 assert.equal(terminal?.status, "failed");
5075 if (terminal?.status !== "failed") return;
5076 assert.equal(terminal.threadDisposition, "reusable");
5077 assert.include(terminal.failure.message, "never started a turn");
5078 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
5079 ),
5080 );
5081
5082 it.effect("keeps a started prompt running until its result", () =>
5083 Effect.scoped(
5084 Effect.gen(function* () {
5085 const harness = yield* makeWakeHarness;
5086 const now = yield* DateTime.now;
5087 yield* harness.runtime.startTurn(
5088 makeClaudeTestTurnInput({
5089 threadId: harness.threadId,
5090 providerThread: harness.providerThread,
5091 now,
5092 attemptId: RunAttemptId.make("attempt-claude-started-prompt"),
5093 text: "Bump this PR to the latest main.",
5094 attachments: [],
5095 }),
5096 );
5097 const lifecycle = (state: string, uuid: string) =>
5098 claudeSdkFrame({
5099 type: "command_lifecycle",
5100 command_uuid: harness.promptUuid(0),
5101 state,
5102 uuid,
5103 session_id: WAKE_NATIVE_SESSION,
5104 });
5105 yield* Queue.offer(
5106 harness.sdkMessages,
5107 lifecycle("queued", "00000000-0000-4000-8000-000000000791"),
5108 );
5109 yield* Queue.offer(
5110 harness.sdkMessages,
5111 lifecycle("started", "00000000-0000-4000-8000-000000000792"),
5112 );
5113 // The CLI can report the prompt completed before its turn's result.
5114 yield* Queue.offer(
5115 harness.sdkMessages,
5116 lifecycle("completed", "00000000-0000-4000-8000-000000000793"),
5117 );
5118 yield* Queue.offer(
5119 harness.sdkMessages,
5120 claudeSdkFrame({
5121 ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000794", result: "Done." }),
5122 user_message_uuid: harness.promptUuid(0),
5123 }),
5124 );
5125 yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal");
5126 assert.equal(harness.terminalEvents()[0]?.status, "completed");
5127 }).pipe(Effect.provide(Layer.merge(IdAllocator.layer, NodeServices.layer))),
5128 ),
5129 );
5130
5021 it.effect("buffers wake output and requests a single continuation run", () =>5131 it.effect("buffers wake output and requests a single continuation run", () =>
5022 Effect.scoped(5132 Effect.scoped(
5023 Effect.gen(function* () {5133 Effect.gen(function* () {
...@@ -6031,12 +6141,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6031,12 +6141,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6031 const userAttempt = RunAttemptId.make("attempt-claude-wake-subagent-2");6141 const userAttempt = RunAttemptId.make("attempt-claude-wake-subagent-2");
6032 const continuationAttempt = RunAttemptId.make("attempt-claude-wake-subagent-3");6142 const continuationAttempt = RunAttemptId.make("attempt-claude-wake-subagent-3");
6033 const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`);6143 const runOf = (attemptId: RunAttemptId) => RunId.make(`run-${attemptId}`);
6034 const stamp = Effect.fnUntraced(function* (frame: SDKMessage, attemptId: RunAttemptId) {6144 const stamp = (frame: SDKMessage, promptIndex: number) =>
6035 return claudeSdkFrame({6145 claudeSdkFrame({
6036 ...frame,6146 ...frame,
6037 user_message_uuid: yield* ClaudeAdapterV2.claudePromptUuid(attemptId),6147 user_message_uuid: harness.promptUuid(promptIndex),
6038 });6148 });
6039 });
60406149
6041 yield* harness.runtime.startTurn(6150 yield* harness.runtime.startTurn(
6042 makeClaudeTestTurnInput({6151 makeClaudeTestTurnInput({
...@@ -6048,12 +6157,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6048,12 +6157,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6048 attachments: [],6157 attachments: [],
6049 }),6158 }),
6050 );6159 );
6051 yield* Queue.offer(harness.sdkMessages, yield* stamp(wakeTaskStarted, firstAttempt));6160 yield* Queue.offer(harness.sdkMessages, stamp(wakeTaskStarted, 0));
6052 yield* Queue.offer(6161 yield* Queue.offer(
6053 harness.sdkMessages,6162 harness.sdkMessages,
6054 yield* stamp(6163 stamp(
6055 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000770", result: "STARTED" }),6164 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000770", result: "STARTED" }),
6056 firstAttempt,6165 0,
6057 ),6166 ),
6058 );6167 );
6059 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");6168 yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal");
...@@ -6120,16 +6229,16 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6120,16 +6229,16 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6120 session_id: WAKE_NATIVE_SESSION,6229 session_id: WAKE_NATIVE_SESSION,
6121 }),6230 }),
6122 wakeResult,6231 wakeResult,
6123 yield* stamp(6232 stamp(
6124 makeAssistantTextFrame({6233 makeAssistantTextFrame({
6125 uuid: "00000000-0000-4000-8000-000000000776",6234 uuid: "00000000-0000-4000-8000-000000000776",
6126 text: "USER_REPLY",6235 text: "USER_REPLY",
6127 }),6236 }),
6128 userAttempt,6237 1,
6129 ),6238 ),
6130 yield* stamp(6239 stamp(
6131 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000777", result: "USER_REPLY" }),6240 makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000777", result: "USER_REPLY" }),
6132 userAttempt,6241 1,
6133 ),6242 ),
6134 ];6243 ];
6135 for (const frame of wakeFrames) {6244 for (const frame of wakeFrames) {
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts+5-2
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
18} from "@t3tools/contracts";18} from "@t3tools/contracts";
19import * as Cause from "effect/Cause";19import * as Cause from "effect/Cause";
20import * as Duration from "effect/Duration";20import * as Duration from "effect/Duration";
21import * as Crypto from "effect/Crypto";
21import * as Effect from "effect/Effect";22import * as Effect from "effect/Effect";
22import * as Exit from "effect/Exit";23import * as Exit from "effect/Exit";
23import * as Layer from "effect/Layer";24import * as Layer from "effect/Layer";
...@@ -1577,11 +1578,13 @@ async function recordClaudeStreamingQuery(input: {...@@ -1577,11 +1578,13 @@ async function recordClaudeStreamingQuery(input: {
1577 };1578 };
1578 try {1579 try {
1579 for (const [index, prompt] of input.prompts.entries()) {1580 for (const [index, prompt] of input.prompts.entries()) {
1580 // Like the adapter, give each prompt a uuid Claude echoes on its turn.1581 // Like the adapter, give each prompt a fresh uuid Claude echoes on its turn.
1581 const message = ClaudeAdapterV2.makeClaudeUserMessage({1582 const message = ClaudeAdapterV2.makeClaudeUserMessage({
1582 text: prompt,1583 text: prompt,
1583 uuid: await Effect.runPromise(1584 uuid: await Effect.runPromise(
1584 ClaudeAdapterV2.claudePromptUuid(`${input.sessionId}:prompt:${index + 1}`).pipe(1585 Crypto.Crypto.pipe(
1586 Effect.flatMap((crypto) => crypto.randomUUIDv4),
1587 Effect.filterOrFail(ClaudeAdapterV2.isClaudePromptUuid),
1585 Effect.provide(NodeServices.layer),1588 Effect.provide(NodeServices.layer),
1586 ),1589 ),
1587 ),1590 ),
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+52-24
...@@ -71,7 +71,6 @@ import {...@@ -71,7 +71,6 @@ import {
71import * as Cause from "effect/Cause";71import * as Cause from "effect/Cause";
72import * as Context from "effect/Context";72import * as Context from "effect/Context";
73import * as Crypto from "effect/Crypto";73import * as Crypto from "effect/Crypto";
74import * as Hex from "effect/encoding/Hex";
75import * as DateTime from "effect/DateTime";74import * as DateTime from "effect/DateTime";
76import * as Deferred from "effect/Deferred";75import * as Deferred from "effect/Deferred";
77import * as Effect from "effect/Effect";76import * as Effect from "effect/Effect";
...@@ -1417,19 +1416,9 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input:...@@ -1417,19 +1416,9 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input:
1417 } satisfies SDKUserMessage;1416 } satisfies SDKUserMessage;
1418});1417});
14191418
1420// Stable per run attempt, so a replayed prompt offer matches its recording.1419export function isClaudePromptUuid(value: string): value is NonNullable<SDKUserMessage["uuid"]> {
1421// Claude echoes it back as user_message_uuid on the turn that answers it.1420 return /^[^-]+-[^-]+-[^-]+-[^-]+-[^-]+$/.test(value);
1422export const claudePromptUuid = Effect.fn("claudePromptUuid")(function* (attemptId: string) {1421}
1423 const crypto = yield* Crypto.Crypto;
1424 const digest = yield* crypto
1425 .digest("SHA-256", new TextEncoder().encode(`t3-claude-prompt:${attemptId}`))
1426 .pipe(Effect.orDie);
1427 const hex = Hex.encode(digest);
1428 const variant = ((Number.parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16);
1429 const uuid: NonNullable<SDKUserMessage["uuid"]> =
1430 `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${variant}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
1431 return uuid;
1432});
14331422
1434type ClaudeAssistantContentBlock = SDKAssistantMessage["message"]["content"][number];1423type ClaudeAssistantContentBlock = SDKAssistantMessage["message"]["content"][number];
1435type ClaudeToolUseContentBlock = Extract<1424type ClaudeToolUseContentBlock = Extract<
...@@ -2477,14 +2466,17 @@ function claudeEchoedPromptUuids(message: SDKMessage): ReadonlyArray<string> {...@@ -2477,14 +2466,17 @@ function claudeEchoedPromptUuids(message: SDKMessage): ReadonlyArray<string> {
2477 return typeof uuid === "string" ? [uuid] : [];2466 return typeof uuid === "string" ? [uuid] : [];
2478}2467}
24792468
2480// The prompt uuid a command_lifecycle frame (queued, started, completed)2469// The prompt a command_lifecycle frame (queued, started, completed)
2481// acknowledges. A CLI that sends them also echoes that uuid on the result of2470// acknowledges. A CLI that sends them also echoes that uuid on the result of
2482// the turn answering the prompt; one that does not keeps the old path.2471// the turn answering the prompt; one that does not keeps the old path.
2483function claudeAcknowledgedPromptUuid(message: SDKMessage): string | null {2472function claudePromptLifecycle(
2473 message: SDKMessage,
2474): { readonly uuid: string; readonly state: string } | null {
2484 const type: unknown = Reflect.get(message, "type");2475 const type: unknown = Reflect.get(message, "type");
2485 if (type !== "command_lifecycle") return null;2476 if (type !== "command_lifecycle") return null;
2486 const uuid: unknown = Reflect.get(message, "command_uuid");2477 const uuid: unknown = Reflect.get(message, "command_uuid");
2487 return typeof uuid === "string" ? uuid : null;2478 const state: unknown = Reflect.get(message, "state");
2479 return typeof uuid === "string" && typeof state === "string" ? { uuid, state } : null;
2488}2480}
24892481
2490// A result that answers a turn other than the pending prompt's. An echo2482// A result that answers a turn other than the pending prompt's. An echo
...@@ -2750,6 +2742,8 @@ interface ActiveClaudeTurnContext {...@@ -2750,6 +2742,8 @@ interface ActiveClaudeTurnContext {
2750 // prompt's turn echoes this uuid (see handleSdkMessage).2742 // prompt's turn echoes this uuid (see handleSdkMessage).
2751 readonly promptUuid: string | null;2743 readonly promptUuid: string | null;
2752 promptEcho: "pending" | "confirmed";2744 promptEcho: "pending" | "confirmed";
2745 // Claude acknowledged starting the prompt (command_lifecycle "started").
2746 promptStarted: boolean;
2753 // Root frames seen before the echo; held only when the CLI echoes early.2747 // Root frames seen before the echo; held only when the CLI echoes early.
2754 gatedFramesBeforeEcho: number;2748 gatedFramesBeforeEcho: number;
2755 readonly heldRootFrames: Array<SDKMessage>;2749 readonly heldRootFrames: Array<SDKMessage>;
...@@ -6801,11 +6795,32 @@ export function makeClaudeAdapterV2(...@@ -6801,11 +6795,32 @@ export function makeClaudeAdapterV2(
6801 yield* handleRoutedSdkMessage(input);6795 yield* handleRoutedSdkMessage(input);
6802 return;6796 return;
6803 }6797 }
6804 if (6798 const lifecycle = claudePromptLifecycle(message);
6805 liveQuery.promptEchoMode === "unknown" &&6799 if (lifecycle?.uuid === context.promptUuid) {
6806 claudeAcknowledgedPromptUuid(message) === context.promptUuid6800 if (liveQuery.promptEchoMode === "unknown") {
6807 ) {6801 liveQuery.promptEchoMode = "acknowledged";
6808 liveQuery.promptEchoMode = "acknowledged";6802 }
6803 if (lifecycle.state === "started") {
6804 context.promptStarted = true;
6805 }
6806 // Claude finishes a prompt whose uuid its transcript already
6807 // holds without starting it, and no turn will ever answer it.
6808 if (lifecycle.state === "completed" && !context.promptStarted) {
6809 yield* Effect.logWarning("orchestration-v2.claude-prompt-completed-without-turn", {
6810 providerTurnId: context.providerTurnId,
6811 promptUuid: context.promptUuid,
6812 });
6813 yield* finalizeActiveTurn({
6814 context,
6815 status: "failed",
6816 completedAt: yield* DateTime.now,
6817 failure: makeProviderFailure({
6818 class: "provider_error",
6819 message: "Claude acknowledged the message but never started a turn for it.",
6820 }),
6821 });
6822 return;
6823 }
6809 }6824 }
6810 if (!isClaudePromptEchoGatedFrame(message)) {6825 if (!isClaudePromptEchoGatedFrame(message)) {
6811 // Frames the held turn produced through its own tool uses (a6826 // Frames the held turn produced through its own tool uses (a
...@@ -7442,10 +7457,22 @@ export function makeClaudeAdapterV2(...@@ -7442,10 +7457,22 @@ export function makeClaudeAdapterV2(
7442 const startedAt = yield* DateTime.now;7457 const startedAt = yield* DateTime.now;
7443 const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread);7458 const nativeThreadId = yield* getNativeThreadId(turnInput.providerThread);
7444 const nativeTurnId = `turn:${turnInput.attemptId}`;7459 const nativeTurnId = `turn:${turnInput.attemptId}`;
7460 // Fresh for every offer: Claude acks a prompt whose uuid its
7461 // transcript already holds without running a turn, and a session
7462 // can outlive this database (or be resumed from a copy of it), so
7463 // a uuid derived from run ids can repeat.
7445 const promptUuid = isClaudeProviderContinuationTurn(turnInput)7464 const promptUuid = isClaudeProviderContinuationTurn(turnInput)
7446 ? null7465 ? null
7447 : yield* claudePromptUuid(turnInput.attemptId).pipe(7466 : yield* crypto.randomUUIDv4.pipe(
7448 Effect.provideService(Crypto.Crypto, crypto),7467 Effect.filterOrFail(isClaudePromptUuid),
7468 Effect.mapError(
7469 (cause) =>
7470 new ProviderAdapter.ProviderAdapterProtocolError({
7471 driver: CLAUDE_PROVIDER,
7472 detail: "Failed to allocate a Claude prompt uuid.",
7473 payload: cause,
7474 }),
7475 ),
7449 );7476 );
7450 const providerTurnId = idAllocator.derive.providerTurn({7477 const providerTurnId = idAllocator.derive.providerTurn({
7451 driver: CLAUDE_PROVIDER,7478 driver: CLAUDE_PROVIDER,
...@@ -7505,6 +7532,7 @@ export function makeClaudeAdapterV2(...@@ -7505,6 +7532,7 @@ export function makeClaudeAdapterV2(
7505 subagentNodesByTaskId: new Map(),7532 subagentNodesByTaskId: new Map(),
7506 promptUuid,7533 promptUuid,
7507 promptEcho: isClaudeProviderContinuationTurn(turnInput) ? "confirmed" : "pending",7534 promptEcho: isClaudeProviderContinuationTurn(turnInput) ? "confirmed" : "pending",
7535 promptStarted: false,
7508 gatedFramesBeforeEcho: 0,7536 gatedFramesBeforeEcho: 0,
7509 heldRootFrames: [],7537 heldRootFrames: [],
7510 };7538 };