Skip to content
Closed
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
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export const RPC_REQUIRED_SCOPES = {
[ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.getNativeChildWork]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: AuthOrchestrationOperateScope,
[ORCHESTRATION_V2_WS_METHODS.stopBackgroundTask]: AuthOrchestrationOperateScope,
[ORCHESTRATION_V2_WS_METHODS.forceStopThread]: AuthOrchestrationOperateScope,
[ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.getFullThreadDiff]: AuthOrchestrationReadScope,
Expand All @@ -61,6 +62,7 @@ export const RPC_REQUIRED_SCOPES = {
[ORCHESTRATION_V2_WS_METHODS.subscribeShell]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.subscribeCommandOutput]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.subscribeBackgroundTaskOutput]: AuthOrchestrationReadScope,
[ORCHESTRATION_V2_WS_METHODS.subscribePromptSuggestion]: AuthOrchestrationReadScope,
[WS_METHODS.projectsMutate]: AuthOrchestrationOperateScope,
[WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ export const make = Effect.gen(function* () {
threadProjectFromScratch: true,
threadTitleRegeneration: true,
commandOutputStreaming: true,
backgroundTaskControl: true,
threadVisitedTracking: true,
threadPullRequests: true,
threadPullRequestWatch: true,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/observability/RpcInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const RPC_AGGREGATES = {
[ORCHESTRATION_V2_WS_METHODS.getWorkflowScript]: "orchestration",
[ORCHESTRATION_V2_WS_METHODS.getNativeChildWork]: "orchestration",
[ORCHESTRATION_V2_WS_METHODS.stopWorkflow]: "orchestration",
[ORCHESTRATION_V2_WS_METHODS.stopBackgroundTask]: "orchestration",
[ORCHESTRATION_V2_WS_METHODS.forceStopThread]: "orchestration",
[ORCHESTRATION_V2_WS_METHODS.getTurnItem]: "orchestration",
[ORCHESTRATION_V2_WS_METHODS.getTurnDiff]: "orchestration",
Expand All @@ -33,6 +34,7 @@ const RPC_AGGREGATES = {
[ORCHESTRATION_V2_WS_METHODS.subscribeShell]: "orchestrationV2",
[ORCHESTRATION_V2_WS_METHODS.subscribeThread]: "orchestrationV2",
[ORCHESTRATION_V2_WS_METHODS.subscribeCommandOutput]: "orchestrationV2",
[ORCHESTRATION_V2_WS_METHODS.subscribeBackgroundTaskOutput]: "orchestrationV2",
[ORCHESTRATION_V2_WS_METHODS.subscribePromptSuggestion]: "orchestrationV2",
[WS_METHODS.projectsMutate]: "orchestration",
[WS_METHODS.serverProbe]: "server",
Expand Down
147 changes: 145 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10826,7 +10826,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
const roster = providerThreadRosterEvents(harness.events).at(-1)?.providerThread
.pendingBackgroundTasks;
assert.deepEqual(roster, [
{ taskId: longRunning.taskId, kind: "monitor", description: "Monitor 0" },
{
taskId: longRunning.taskId,
kind: "monitor",
description: "Monitor 0",
individualControl: true,
},
]);
}).pipe(
Effect.provide(
Expand Down Expand Up @@ -11424,6 +11429,144 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);

// Windows paths need no uid, so this runs on every host.
it.effect("follows and stops one background Bash task the roster lists", () =>
Effect.scoped(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tmp = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-claude-background-output-",
});
const stoppedTasks: Array<string> = [];
const harness = yield* makeWakeHarnessWithOptions({
environment: { CLAUDE_CODE_TMPDIR: tmp },
stopTask: (taskId) =>
Effect.sync(() => {
stoppedTasks.push(taskId);
}),
});
const taskId = "dev-server-task";
const tasks = path.join(tmp, "claude", "project", WAKE_NATIVE_SESSION, "tasks");
yield* fileSystem.makeDirectory(tasks, { recursive: true });
yield* fileSystem.writeFileString(path.join(tasks, `${taskId}.output`), "ready on :5173\n");

yield* harness.runtime.startTurn(
makeClaudeTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("attempt-claude-background-output"),
text: "Start the dev server in the background.",
attachments: [],
}),
);
yield* Queue.offer(
harness.sdkMessages,
claudeSdkFrame({
type: "system",
subtype: "task_started",
task_id: taskId,
description: "vp run dev",
is_backgrounded: true,
task_type: "local_bash",
uuid: "00000000-0000-4000-8000-000000000b01",
session_id: WAKE_NATIVE_SESSION,
}),
);
while (
!(yield* Queue.take(
harness.providerThreadReceipts,
)).providerThread.pendingBackgroundTasks?.some((task) => task.taskId === taskId)
);

const output = harness.runtime.backgroundTaskOutput!;
const stop = harness.runtime.stopBackgroundTask!;
const target = { providerThread: harness.providerThread, taskId };
assert.deepEqual(
Array.from(yield* Stream.runCollect(output(target).pipe(Stream.take(1)))),
["ready on :5173\n"],
);

const missing = { ...target, taskId: "already-finished" };
assert.isTrue(Exit.isFailure(yield* Stream.runCollect(output(missing)).pipe(Effect.exit)));
assert.isTrue(Exit.isFailure(yield* stop(missing).pipe(Effect.exit)));
yield* stop(target);
assert.deepEqual(stoppedTasks, [taskId]);

// A command that sends its stdout to a file is followed there, not in Claude's capture.
const logFile = path.join(tmp, "dev.log");
yield* fileSystem.writeFileString(logFile, "listening\n");
for (const frame of [
claudeSdkFrame({
type: "assistant",
message: {
model: "claude-sonnet-4-6",
id: "msg_redirected_bash",
type: "message",
role: "assistant",
content: [
{
type: "tool_use",
id: "toolu_redirected_bash",
name: "Bash",
input: {
command: `vp run dev > "${logFile}" 2>&1`,
run_in_background: true,
},
},
],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },
},
parent_tool_use_id: null,
uuid: "00000000-0000-4000-8000-000000000b02",
session_id: WAKE_NATIVE_SESSION,
}),
claudeSdkFrame({
type: "system",
subtype: "task_started",
task_id: "redirected-task",
tool_use_id: "toolu_redirected_bash",
description: "vp run dev",
is_backgrounded: true,
task_type: "local_bash",
uuid: "00000000-0000-4000-8000-000000000b03",
session_id: WAKE_NATIVE_SESSION,
}),
]) {
yield* Queue.offer(harness.sdkMessages, frame);
}
let listed: OrchestrationV2PendingBackgroundTask | undefined;
while (listed === undefined) {
listed = (yield* Queue.take(
harness.providerThreadReceipts,
)).providerThread.pendingBackgroundTasks?.find(
(task) => task.taskId === "redirected-task",
);
}
// Clients label the output with the file the server follows.
assert.equal(listed.outputFile, logFile);
const redirected = { ...target, taskId: "redirected-task" };
assert.deepEqual(
Array.from(yield* Stream.runCollect(output(redirected).pipe(Stream.take(1)))),
["listening\n"],
);
}).pipe(
Effect.provideService(HostProcess.Platform, "win32"),
Effect.provide(
Layer.mergeAll(
IdAllocator.layer,
McpProviderSessions.layer,
NodeServices.layer,
NodeCrypto.layer,
),
),
),
),
);

it.effect("lets the native SDK acknowledge Stop before closing its own query", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down Expand Up @@ -12110,7 +12253,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
(event) => event.providerThread.id === providerThreadA.id,
)?.providerThread.pendingBackgroundTasks;
assert.deepEqual(rosterAAfterSettle ?? [], [
{ taskId: taskA, description: "work on A", kind: "command" },
{ taskId: taskA, description: "work on A", kind: "command", individualControl: true },
]);
assert.isTrue(yield* hasPendingBackgroundWork);
assert.isTrue(yield* hasPendingBackgroundWorkForThread(providerThreadA));
Expand Down
Loading
Loading