Skip to content

Commit 07242ab

Browse files
authored
fix(server): recover handoffs when target context is exhausted (#25610)
1 parent 740de0e commit 07242ab

10 files changed

Lines changed: 816 additions & 342 deletions

File tree

‎apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts‎

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -909,7 +909,11 @@ describe("AcpAdapterV2", () => {
909909
flavor: {
910910
driver: ACP_TEST_DRIVER,
911911
capabilities: AcpProviderCapabilitiesV2,
912-
makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }),
912+
makeRuntime: makeMockRuntime({
913+
childProcessSpawner,
914+
mockAgentPath,
915+
protocolEvents,
916+
}),
913917
},
914918
selfInvocation,
915919
});
@@ -2406,6 +2410,99 @@ describe("AcpAdapterV2", () => {
24062410
}).pipe(Effect.provide(layerTest), Effect.scoped),
24072411
);
24082412

2413+
it.live("creates a fresh ACP identity when recovering an exhausted target", () =>
2414+
Effect.gen(function* () {
2415+
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
2416+
const path = yield* Path.Path;
2417+
const selfInvocation = yield* resolveSelfInvocation();
2418+
const mockAgentPath = yield* path.fromFileUrl(
2419+
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
2420+
);
2421+
const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>();
2422+
const instanceId = ProviderInstanceId.make("acp-test-exhausted-target");
2423+
const adapter = yield* makeAcpAdapterV2({
2424+
instanceId,
2425+
flavor: {
2426+
driver: ACP_TEST_DRIVER,
2427+
capabilities: AcpProviderCapabilitiesV2,
2428+
makeRuntime: makeMockRuntime({
2429+
childProcessSpawner,
2430+
mockAgentPath,
2431+
protocolEvents,
2432+
wrapRuntime: (runtime, ordinal) => ({
2433+
...runtime,
2434+
start: () =>
2435+
runtime.start().pipe(
2436+
Effect.map((started) => ({
2437+
...started,
2438+
sessionId: `mock-recovery-session-${ordinal}`,
2439+
})),
2440+
),
2441+
}),
2442+
}),
2443+
},
2444+
selfInvocation,
2445+
});
2446+
const threadId = ThreadId.make("thread-acp-exhausted-target");
2447+
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
2448+
runtimeMode: "full-access",
2449+
interactionMode: "default",
2450+
cwd: process.cwd(),
2451+
});
2452+
const modelSelection = { instanceId, model: "default" } as const;
2453+
const runtime = yield* adapter.openSession({
2454+
threadId,
2455+
providerSessionId: ProviderSessionId.make("provider-session-acp-exhausted-target"),
2456+
modelSelection,
2457+
runtimePolicy,
2458+
});
2459+
const original = yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy });
2460+
yield* runtime.startTurn(
2461+
makeTurnInput({
2462+
threadId,
2463+
providerThread: original,
2464+
instanceId,
2465+
runtimePolicy,
2466+
now: yield* DateTime.now,
2467+
ordinal: 1,
2468+
}),
2469+
);
2470+
yield* runtime.events.pipe(
2471+
Stream.takeUntil((event) => event.type === "turn.terminal"),
2472+
Stream.runDrain,
2473+
);
2474+
const replacement = yield* runtime.ensureThread({
2475+
threadId,
2476+
modelSelection,
2477+
runtimePolicy,
2478+
existingProviderThread: { ...original, nativeThreadRef: null },
2479+
forceNewNativeThread: true,
2480+
});
2481+
assert.notEqual(replacement.nativeThreadRef?.nativeId, original.nativeThreadRef?.nativeId);
2482+
assert.equal(replacement.id, original.id);
2483+
assert.equal(replacement.nativeMetadata?.itemIdentityVersion, 2);
2484+
yield* runtime.startTurn(
2485+
makeTurnInput({
2486+
threadId,
2487+
providerThread: replacement,
2488+
instanceId,
2489+
runtimePolicy,
2490+
now: yield* DateTime.now,
2491+
ordinal: 2,
2492+
}),
2493+
);
2494+
const events = yield* runtime.events.pipe(
2495+
Stream.takeUntil((event) => event.type === "turn.terminal"),
2496+
Stream.runCollect,
2497+
);
2498+
assert.isTrue(
2499+
events.some((event) => event.type === "turn.terminal" && event.status === "completed"),
2500+
);
2501+
const methods = yield* pollProtocolMethods(protocolEvents);
2502+
assert.equal(methods.filter((method) => method === "session/new").length, 2);
2503+
}).pipe(Effect.provide(layerTest), Effect.scoped),
2504+
);
2505+
24092506
it.live("preserves new-session fallback when an eager ACP session load is stale", () =>
24102507
Effect.gen(function* () {
24112508
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -5240,6 +5337,19 @@ describe("AcpAdapterV2", () => {
52405337
yield* hasPendingBackgroundWork,
52415338
"carryover live subagent must pin hasPendingBackgroundWork after root settle",
52425339
);
5340+
const refusedReplacement = yield* runtime
5341+
.ensureThread({
5342+
threadId,
5343+
modelSelection,
5344+
runtimePolicy,
5345+
existingProviderThread: { ...providerThread, nativeThreadRef: null },
5346+
forceNewNativeThread: true,
5347+
})
5348+
.pipe(Effect.flip);
5349+
assert.equal(refusedReplacement._tag, "ProviderAdapterEnsureThreadError");
5350+
const preserved = yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy });
5351+
assert.equal(preserved.nativeThreadRef?.nativeId, providerThread.nativeThreadRef?.nativeId);
5352+
assert.isTrue(yield* hasPendingBackgroundWork);
52435353
assert.isDefined(sessionUpdateHandler, "session update handler must be wired");
52445354

52455355
subagentPhase = "complete";

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts‎

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7784,7 +7784,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
77847784
),
77857785
);
77867786

7787-
it.effect("stops background work after the turn settled", () =>
7787+
it.effect("preserves settled background work until an explicit stop", () =>
77887788
Effect.scoped(
77897789
Effect.gen(function* () {
77907790
let closes = 0;
@@ -7813,6 +7813,29 @@ describe("ClaudeAdapterV2 background wake turns", () => {
78137813
assert.equal(settledThread?.pendingBackgroundTasks?.[0]?.taskId, WAKE_TASK_ID);
78147814
assert.isTrue(yield* harness.hasPendingBackgroundWork);
78157815

7816+
const replacementAttempt = yield* harness.runtime
7817+
.startTurn(
7818+
makeClaudeTestTurnInput({
7819+
threadId: harness.threadId,
7820+
providerThread: {
7821+
...harness.providerThread,
7822+
nativeThreadRef: {
7823+
...harness.providerThread.nativeThreadRef!,
7824+
nativeId: "replacement-session",
7825+
},
7826+
},
7827+
now,
7828+
attemptId: RunAttemptId.make("attempt-claude-background-replacement"),
7829+
text: "Continue in a fresh session.",
7830+
attachments: [],
7831+
providerTurnOrdinal: 2,
7832+
}),
7833+
)
7834+
.pipe(Effect.result);
7835+
assert.equal(replacementAttempt._tag, "Failure");
7836+
assert.equal(closes, 0);
7837+
assert.isTrue(yield* harness.hasPendingBackgroundWork);
7838+
78167839
yield* harness.runtime.interruptTurn({
78177840
providerThread: settledThread ?? harness.providerThread,
78187841
providerTurnId: harness.terminalEvents()[0]!.providerTurnId,
@@ -12240,6 +12263,25 @@ describe("ClaudeAdapterV2 background wake turns", () => {
1224012263
yield* changes.until(() => terminals().length === 1);
1224112264
const settledTurn = terminals()[0]!;
1224212265

12266+
assert.isFalse(yield* runtime.hasPendingBackgroundWorkForThread!(providerThread));
12267+
assert.isTrue(
12268+
yield* runtime.hasPendingBackgroundWorkForThread!(providerThread, {
12269+
includeSubagents: true,
12270+
}),
12271+
);
12272+
assert.isFalse(
12273+
yield* runtime.hasPendingBackgroundWorkForThread!(
12274+
{
12275+
...providerThread,
12276+
nativeThreadRef: {
12277+
...providerThread.nativeThreadRef!,
12278+
nativeId: "sibling-native-session",
12279+
},
12280+
},
12281+
{ includeSubagents: true },
12282+
),
12283+
);
12284+
1224312285
const alternateModel = CLAUDE_TEST_RESTART_MODEL_SELECTION;
1224412286
const switchTurn = (attempt: string) =>
1224512287
runtime.startTurn(

‎apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9692,10 +9692,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
96929692
return existing;
96939693
}
96949694

9695-
// Selection changes kill CLI background work; refuse until it finishes or Stop closes the process.
9695+
// Replacing this thread's process kills its background work, even with a new native identity.
96969696
if (
96979697
existing !== null &&
9698-
existing.nativeThreadId === nativeThreadId &&
9698+
(existing.nativeThreadId === nativeThreadId ||
9699+
existing.lastTurn?.input.threadId === turnInput.threadId) &&
96999700
!existing.stopping &&
97009701
(yield* liveProcessRunsBackgroundWork(existing))
97019702
) {
@@ -10560,11 +10561,20 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
1056010561
}),
1056110562
hasPendingBackgroundWorkForThread: Effect.fn(
1056210563
"ClaudeAdapterV2.hasPendingBackgroundWorkForThread",
10563-
)(function* (providerThread) {
10564+
)(function* (providerThread, options) {
1056410565
const nativeThreadId = providerThread.nativeThreadRef?.nativeId;
1056510566
if (nativeThreadId === undefined || nativeThreadId === null) {
1056610567
return false;
1056710568
}
10569+
if (options?.includeSubagents) {
10570+
const live = yield* Ref.get(queryContext);
10571+
if (
10572+
live?.nativeThreadId === nativeThreadId &&
10573+
!live.stopping &&
10574+
(yield* liveProcessRunsBackgroundWork(live))
10575+
)
10576+
return true;
10577+
}
1056810578
return (
1056910579
rosterForNativeThread(
1057010580
yield* Ref.get(pendingBackgroundTasksByNativeThread),

0 commit comments

Comments
 (0)