Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This repeats the wake attachment sequence in starts the wake run when Claude opens the wake turn. Could the stale-clear scenario be folded into that existing test, retaining the second wake and final assertion?

Posted via Macroscope — Test Signal

Original file line number Diff line number Diff line change
Expand Up @@ -13766,6 +13766,161 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

it.effect("offers a later wake after an earlier wake's continuation dispatch failed", () =>
Effect.scoped(
Effect.gen(function* () {
const SECOND_TASK_ID = "b2ndwake";
const SECOND_TOOL_USE_ID = "toolu_second_wake";
const SECOND_SUMMARY = 'Background command "Second sleep" completed (exit code 0)';
const harness = yield* makeWakeHarness;
const now = yield* DateTime.now;

yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-wake-dispatch-failed-1"),
text: "Run two builds in the background.",
attachments: [],
}),
);
yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
...wakeTaskStarted,
task_id: SECOND_TASK_ID,
tool_use_id: SECOND_TOOL_USE_ID,
description: "Second sleep",
uuid: "00000000-0000-4000-8000-000000000121",
}),
);
yield* Queue.offer(harness.sdkMessages, turnOneResult);
yield* harness.awaitTerminal(1);

yield* harness.offerAndWait(wakeNotification);
yield* harness.offerAndWait(wakeTurnInit);
assert.lengthOf(harness.continuationRequests, 1);
yield* harness.offerAndWait(wakeAssistant);
yield* harness.offerAndWait(wakeResult);

// A failed dispatch never attaches the wake turn; clearIfCurrent is the only release.
const failed = harness.continuationRequests[0]!;
assert.isDefined(failed.clearIfCurrent);
yield* failed.clearIfCurrent!();

yield* harness.offerAndWait(
claudeSdkFrame({
...wakeNotification,
task_id: SECOND_TASK_ID,
tool_use_id: SECOND_TOOL_USE_ID,
output_file: `/tmp/claude-replay/tasks/${SECOND_TASK_ID}.output`,
summary: SECOND_SUMMARY,
uuid: "00000000-0000-4000-8000-000000000123",
}),
);
yield* harness.offerAndWait(
claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000124" }),
);
assert.lengthOf(harness.continuationRequests, 2);
}).pipe(
Effect.provide(
Layer.mergeAll(
IdAllocator.layer,
McpProviderSessions.layer,
NodeServices.layer,
NodeCrypto.layer,
),
),
),
),
);

it.effect("keeps a newer wake latched when a stale continuation request is cleared", () =>
Effect.scoped(
Effect.gen(function* () {
const SECOND_TASK_ID = "b2ndwake";
const SECOND_TOOL_USE_ID = "toolu_second_wake";
const harness = yield* makeWakeHarness;
const now = yield* DateTime.now;

yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-wake-stale-clear-1"),
text: "Run two builds in the background.",
attachments: [],
}),
);
yield* Queue.offer(harness.sdkMessages, wakeTaskStarted);
yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
...wakeTaskStarted,
task_id: SECOND_TASK_ID,
tool_use_id: SECOND_TOOL_USE_ID,
description: "Second sleep",
uuid: "00000000-0000-4000-8000-000000000131",
}),
);
yield* Queue.offer(harness.sdkMessages, turnOneResult);
yield* harness.awaitTerminal(1);

yield* harness.offerAndWait(wakeNotification);
yield* harness.offerAndWait(wakeTurnInit);
assert.lengthOf(harness.continuationRequests, 1);
yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-wake-stale-clear-2"),
text: "Background task completed.",
attachments: [],
providerTurnOrdinal: 2,
messageCreatedBy: "agent",
messageCreationSource: "provider",
}),
);
yield* harness.offerAndWait(wakeAssistant);
yield* harness.offerAndWait(wakeResult);
yield* harness.awaitTerminal(2);

yield* harness.offerAndWait(
claudeSdkFrame({
...wakeNotification,
task_id: SECOND_TASK_ID,
tool_use_id: SECOND_TOOL_USE_ID,
output_file: `/tmp/claude-replay/tasks/${SECOND_TASK_ID}.output`,
uuid: "00000000-0000-4000-8000-000000000133",
}),
);
yield* harness.offerAndWait(
claudeSdkFrame({ ...wakeTurnInit, uuid: "00000000-0000-4000-8000-000000000134" }),
);
assert.lengthOf(harness.continuationRequests, 2);

yield* harness.continuationRequests[0]!.clearIfCurrent!();
yield* harness.offerAndWait(
claudeSdkFrame({ ...wakeAssistant, uuid: "00000000-0000-4000-8000-000000000135" }),
);
assert.lengthOf(harness.continuationRequests, 2);
}).pipe(
Effect.provide(
Layer.mergeAll(
IdAllocator.layer,
McpProviderSessions.layer,
NodeServices.layer,
NodeCrypto.layer,
),
),
),
),
);

it.effect.each([true, false])(
"carries a rejected wake rate limit into the continuation failure (event=%s)",
(standaloneEvent) =>
Expand Down
24 changes: 18 additions & 6 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5569,7 +5569,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
return { kind: "command", label, outcome };
}
});
const requestedContinuations = yield* Ref.make(new Set<string>());
const requestedContinuations = yield* Ref.make(new Map<string, symbol>());
const absorbedWakes = yield* Ref.make(new Set<string>());
const heldProposedPlansByToolUseId = new Map<string, string>();
const runtimeContext = yield* Effect.context<never>();
Expand Down Expand Up @@ -6052,7 +6052,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
if (!current.has(nativeThreadId)) {
return current;
}
const updated = new Set(current);
const updated = new Map(current);
updated.delete(nativeThreadId);
return updated;
});
Expand Down Expand Up @@ -8911,12 +8911,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
});
return;
}
const nativeThreadId = wakeInput.nativeThreadId;
const latch = Symbol(nativeThreadId);
const shouldOffer = yield* Ref.modify(requestedContinuations, (current) => {
if (current.has(wakeInput.nativeThreadId)) {
if (current.has(nativeThreadId)) {
return [false, current] as const;
}
const updated = new Set(current);
updated.add(wakeInput.nativeThreadId);
const updated = new Map(current);
updated.set(nativeThreadId, latch);
return [true, updated] as const;
});
if (!shouldOffer) {
Expand All @@ -8939,6 +8941,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
driver: CLAUDE_PROVIDER,
detail,
...(notification === null ? {} : { notification }),
// A process reset can clear and re-latch this thread; a stale failure must not release the newer wake.
clearIfCurrent: () =>
Ref.update(requestedContinuations, (current) => {
if (current.get(nativeThreadId) !== latch) {
return current;
}
const updated = new Map(current);
updated.delete(nativeThreadId);
return updated;
}),
});
});

Expand Down Expand Up @@ -11760,7 +11772,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
yield* attachTurn;
const drained = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? [];
yield* Ref.update(requestedContinuations, (current) => {
const updated = new Set(current);
const updated = new Map(current);
updated.delete(nativeThreadId);
return updated;
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { assert, describe, it } from "@effect/vitest";
import {
CommandId,
MessageId,
ProjectId,
ProviderDriverKind,
Expand All @@ -22,6 +23,7 @@ import * as TestClock from "effect/testing/TestClock";

import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests";
import * as Orchestrator from "./Orchestrator.ts";
import * as ProviderContinuationService from "./ProviderContinuationService.ts";
import * as ThreadManagementService from "./ThreadManagementService.ts";

Expand Down Expand Up @@ -418,6 +420,43 @@ describe("ProviderContinuationService", () => {
},
);

it.effect(
"clears a sticky continuation offer when a non-delegated wake dispatch fails generically",
() => {
return Effect.gen(function* () {
const cleared = yield* Deferred.make<void>();
const layerThreads = Layer.mock(ThreadManagementService.ThreadManagementService)({
getThreadRecords: () => Effect.succeed(projection),
dispatch: (command) =>
Effect.fail(
new Orchestrator.OrchestratorDispatchError({
commandId: CommandId.make(String(command.commandId)),
commandType: command.type,
cause: new Error("SQLITE_FULL: database or disk is full"),
}),
),
});
const layerWorker = ProviderContinuationService.layer.pipe(
Layer.provide(
Layer.mergeAll(IdAllocator.layer, ProviderContinuationRequests.layer, layerThreads),
),
);

yield* Effect.gen(function* () {
const requests = yield* ProviderContinuationRequests.ProviderContinuationRequests;
yield* requests.offer({
...request(),
clearIfCurrent: () => Deferred.succeed(cleared, undefined),
});
yield* Deferred.await(cleared);
}).pipe(
Effect.provide(Layer.merge(ProviderContinuationRequests.layer, layerWorker)),
Effect.scoped,
);
});
},
);

it.effect(
"drops stopped and disposed delegated completions instead of reviving them after recovery",
() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,8 @@ export const layer = Layer.effectDiscard(
),
Effect.forkScoped,
);
} else if (request.clearIfCurrent !== undefined) {
yield* request.clearIfCurrent();
}
}),
),
Expand Down
Loading