Skip to content
Open
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
147 changes: 147 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1828,6 +1828,153 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("reports lost background agents when the stream ends cleanly", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
const runtimeEvents: Array<ProviderRuntimeEvent> = [];
const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
Effect.sync(() => {
runtimeEvents.push(event);
}),
).pipe(Effect.forkChild);

yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});
yield* adapter.sendTurn({
threadId: THREAD_ID,
input: "spawn agents",
attachments: [],
});

harness.query.emit({
type: "system",
subtype: "task_started",
task_id: "task-lost-a",
description: "Pass 5: fix-wave regression",
task_type: "local_agent",
tool_use_id: "toolu_lost_a",
uuid: "task-lost-a-uuid",
session_id: "sdk-session",
} as unknown as SDKMessage);
harness.query.emit({
type: "system",
subtype: "task_started",
task_id: "task-lost-b",
description: "Pass 5: blind feature sweep",
task_type: "local_agent",
tool_use_id: "toolu_lost_b",
uuid: "task-lost-b-uuid",
session_id: "sdk-session",
} as unknown as SDKMessage);

// The claude child process exiting ends the SDK iterable cleanly.
// This is the incident shape: background agents still live, no error.
harness.query.finish();

yield* Effect.yieldNow;
yield* Effect.yieldNow;
yield* Effect.yieldNow;
runtimeEventsFiber.interruptUnsafe();

const runtimeError = runtimeEvents.find((event) => event.type === "runtime.error");
assert.equal(runtimeError?.type, "runtime.error");
if (runtimeError?.type === "runtime.error") {
assert.match(runtimeError.payload.message, /2 background agents were still running/);
assert.match(runtimeError.payload.message, /Pass 5: fix-wave regression/);
assert.match(runtimeError.payload.message, /Pass 5: blind feature sweep/);
assert.match(runtimeError.payload.message, /sending a new message resumes the session/i);
}

const stoppedTasks = runtimeEvents.filter(
(event) => event.type === "task.completed" && event.payload.status === "stopped",
);
assert.deepEqual(
stoppedTasks
.map((event) => (event.type === "task.completed" ? String(event.payload.taskId) : ""))
.sort(),
["task-lost-a", "task-lost-b"],
);

assert.equal(
runtimeEvents.some((event) => event.type === "session.exited"),
true,
);
assert.equal(yield* adapter.hasSession(THREAD_ID), false);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("reports lost background agents when the stream fails", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
const runtimeEvents: Array<ProviderRuntimeEvent> = [];
const runtimeEventsFiber = yield* Stream.runForEach(adapter.streamEvents, (event) =>
Effect.sync(() => {
runtimeEvents.push(event);
}),
).pipe(Effect.forkChild);

yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});
yield* adapter.sendTurn({
threadId: THREAD_ID,
input: "spawn agents",
attachments: [],
});

harness.query.emit({
type: "system",
subtype: "task_started",
task_id: "task-lost-on-failure",
description: "Pass 5: concurrency and consistency",
task_type: "local_agent",
tool_use_id: "toolu_lost_failure",
uuid: "task-lost-failure-uuid",
session_id: "sdk-session",
} as unknown as SDKMessage);

harness.query.fail(new Error("stream transport died"));

yield* Effect.yieldNow;
yield* Effect.yieldNow;
yield* Effect.yieldNow;
runtimeEventsFiber.interruptUnsafe();

// Trigger-agnostic: a failure exit with live tasks must be as loud as
// a clean one.
const lossReport = runtimeEvents.find(
(event) =>
event.type === "runtime.error" &&
/background agent was still running/.test(event.payload.message),
);
assert.equal(lossReport?.type, "runtime.error");
if (lossReport?.type === "runtime.error") {
assert.match(lossReport.payload.message, /Pass 5: concurrency and consistency/);
}

const stoppedTask = runtimeEvents.find(
(event) => event.type === "task.completed" && event.payload.status === "stopped",
);
assert.equal(stoppedTask?.type, "task.completed");
if (stoppedTask?.type === "task.completed") {
assert.equal(String(stoppedTask.payload.taskId), "task-lost-on-failure");
}
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("keeps Claude stream failure events structural", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
67 changes: 67 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3567,6 +3567,34 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
return;
}

// Background agents run inside the claude child process; the stream
// ending means that process is gone, and every still-tracked task with
// it. Report the loss before teardown — the previous behavior was a
// silent kill the user only discovered on their next message. The
// structured log captures the exit shape so the upstream trigger (what
// ends the stream minutes after a turn settles) stays measurable.
const lostTasks = Array.from(context.liveTaskIds, (taskId) => ({
taskId,
description: context.taskAgents.get(taskId)?.description,
}));
if (lostTasks.length > 0) {
yield* Effect.logWarning("claude.session.stream-ended-with-live-tasks", {
threadId: context.session.threadId,
exitKind: Exit.isFailure(exit) ? "failure" : "clean-end",
liveTaskCount: lostTasks.length,
taskDescriptions: lostTasks.map((task) => task.description ?? task.taskId),
sessionStartedAt: context.startedAt,
hadActiveTurn: context.turnState !== undefined,
});
const taskNames = lostTasks.map((task) => task.description ?? task.taskId).join(", ");
yield* emitRuntimeError(
context,
lostTasks.length === 1
? `Claude runtime exited while a background agent was still running: ${taskNames}. The agent was stopped; sending a new message resumes the session.`
: `Claude runtime exited while ${lostTasks.length} background agents were still running: ${taskNames}. The agents were stopped; sending a new message resumes the session.`,
);
}

if (Exit.isFailure(exit)) {
if (isClaudeInterruptedCause(exit.cause)) {
if (context.turnState) {
Expand Down Expand Up @@ -3620,6 +3648,39 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
}
context.pendingApprovals.clear();

// Background agents live inside the claude child process, so no task
// survives its session. Emit the terminal event stopTask would have
// produced (see interruptTurn) so durable UI state settles at teardown
// instead of showing phantom running agents until the next resume.
const drainLiveTasks = Effect.gen(function* () {
for (const taskId of Array.from(context.liveTaskIds)) {
// A task_notification handler suspended mid-yield can resume between
// the snapshot above and this iteration and emit the task's real
// terminal event. `delete` returning false means exactly that — the
// task already settled, and emitting a second, contradictory
// `stopped` row here would overwrite its true final status.
if (!context.liveTaskIds.delete(taskId)) {
continue;
}
const taskStamp = yield* makeEventStamp();
yield* offerRuntimeEvent({
type: "task.completed",
eventId: taskStamp.eventId,
provider: PROVIDER,
createdAt: taskStamp.createdAt,
threadId: context.session.threadId,
...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}),
payload: {
taskId: RuntimeTaskId.make(taskId),
status: "stopped",
...taskLinkageFor(context.taskAgents, taskId),
},
providerRefs: nativeProviderRefs(context),
});
}
});
yield* drainLiveTasks;

if (context.turnState) {
yield* completeTurn(context, "interrupted", "Session stopped.");
}
Expand All @@ -3632,6 +3693,12 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
yield* Fiber.interrupt(streamFiber);
}

// A task_started handler suspended mid-yield on the stream fiber can
// resume between the first drain and the interrupt above, re-adding a
// task after it was drained. The fiber is dead now, so one more drain
// makes "zero live tasks after teardown" an invariant, not a race.
yield* drainLiveTasks;

yield* Effect.try({
try: () => context.query.close(),
catch: (cause) =>
Expand Down
Loading
Loading