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
13 changes: 13 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2558,6 +2558,19 @@ describe("ClaudeAdapterV2 native fork", () => {
assert.equal(forkedProviderThread.forkedFrom?.providerThreadId, sourceProviderThread.id);
assert.equal(forkedProviderThread.forkedFrom?.providerTurnId, providerTurnId);

// Claude Code re-arms the copied transcript's unmet goal, including one its Stop-hook cap
// ended, which T3 records as complete.
yield* runtime.forkThread({
sourceProviderThread: {
...sourceProviderThread,
goal: { objective: "Ship the release", status: "complete", checks: 9 },
},
targetThreadId,
});
const clearGoal = forkCalls[1]?.clearGoal?.("forked-native-session");
assert.equal(clearGoal?.resume, "forked-native-session");
assert.deepEqual(clearGoal?.tools, []);

yield* runtime.startTurn({
appThread: {
createdBy: "user",
Expand Down
98 changes: 89 additions & 9 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,8 @@ export interface ClaudeAgentSdkSessionForkInput {
readonly environment: NodeJS.ProcessEnv;
readonly threadId: ThreadId;
readonly providerSessionId: OrchestrationV2ProviderSession["id"];
/** Options that resume the fork, given when the copied transcript carries a `/goal` to clear. */
readonly clearGoal?: (forkSessionId: string) => ClaudeAgentSdkQueryOptions;
}

export interface ClaudeAgentSdkSubagentLookupInput {
Expand Down Expand Up @@ -1085,6 +1087,9 @@ const decodeClaudeHistoryFork = Schema.decodeUnknownEffect(
Schema.Struct({ sessionId: Schema.String, headMessageId: Schema.optionalKey(Schema.String) }),
),
);
const decodeClaudeHistoryMessages = Schema.decodeUnknownEffect(
Schema.fromJsonString(Schema.Array(Schema.Struct({ uuid: Schema.String }))),
);

const claudeConfigDirOf = (
path: Path.Path,
Expand All @@ -1095,7 +1100,7 @@ const claudeConfigDirOf = (
// The SDK reads CLAUDE_CONFIG_DIR from process.env. Isolate custom homes in a
// worker so concurrent providers keep the server's environment.
const runClaudeWorker = Effect.fn("runClaudeWorker")(function* (input: {
readonly method: "forkSession";
readonly method: "forkSession" | "getSessionMessages";
readonly sessionId: string;
readonly options?: ForkSessionOptions;
readonly environment: NodeJS.ProcessEnv;
Expand Down Expand Up @@ -1166,7 +1171,7 @@ export const layerQueryRunner: Layer.Layer<
const hostEnvironment = yield* HostProcess.Environment;
const portDiscovery = yield* PortScanner.PortDiscovery;

return ClaudeAgentSdkQueryRunner.of({
const runner: ClaudeAgentSdkQueryRunnerShape = ClaudeAgentSdkQueryRunner.of({
allocateSessionId: random.uuidV4(),
open: Effect.fn("ClaudeAgentSdkQueryRunner.open")(
function* (input: ClaudeAgentSdkQueryOpenInput) {
Expand Down Expand Up @@ -1603,22 +1608,62 @@ export const layerQueryRunner: Layer.Layer<
sessionId: result.sessionId,
},
});
// Claude Code re-arms an unmet goal's Stop hook from the copied transcript on resume.
// Clearing it is a local command, and runs before the head is read so rollback keeps it.
if (input.clearGoal !== undefined) {
const session = yield* runner.open({
options: input.clearGoal(result.sessionId),
threadId: input.threadId,
providerSessionId: input.providerSessionId,
});
yield* session.offer(makeClaudeUserMessage({ text: "/goal clear" })).pipe(
Effect.andThen(
session.messages.pipe(
Stream.takeUntil((message) => message.type === "result"),
Stream.runLast,
),
),
Effect.filterOrFail(
(last) =>
Option.isSome(last) &&
last.value.type === "result" &&
last.value.subtype === "success" &&
!last.value.is_error,
() => new Error("Claude did not confirm /goal clear on the fork."),
),
Effect.timeout("30 seconds"),
Effect.mapError((cause) => queryRunnerError(cause, "clearGoal")),
Effect.ensuring(session.close.pipe(Effect.ignore)),
);
}
// The SDK remaps copied UUIDs. Capture the inherited head before the
// fork's first run appends messages.
const historyOptions = input.options.dir === undefined ? {} : { dir: input.options.dir };
const headMessageId = usesHostConfig
? yield* Effect.tryPromise({
try: () =>
getSessionMessages(
result.sessionId,
input.options.dir === undefined ? {} : { dir: input.options.dir },
),
try: () => getSessionMessages(result.sessionId, historyOptions),
catch: (cause) => queryRunnerError(cause, "getSessionMessages"),
}).pipe(
Effect.map((messages) => messages.at(-1)?.uuid),
Effect.orElseSucceed(() => undefined),
)
: result.headMessageId;
return headMessageId === undefined ? result : { ...result, headMessageId };
: input.clearGoal === undefined
? result.headMessageId
: yield* runClaudeWorker({
method: "getSessionMessages",
sessionId: result.sessionId,
options: historyOptions,
environment: input.environment,
}).pipe(
Effect.flatMap(decodeClaudeHistoryMessages),
Effect.map((messages) => messages.at(-1)?.uuid),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provideService(Path.Path, path),
Effect.orElseSucceed(() => undefined),
);
return headMessageId === undefined
? { sessionId: result.sessionId }
: { sessionId: result.sessionId, headMessageId };
}),
subagentLaunchToolUseId: Effect.fn("ClaudeAgentSdkQueryRunner.subagentLaunchToolUseId")(
function* (input: ClaudeAgentSdkSubagentLookupInput) {
Expand Down Expand Up @@ -1657,6 +1702,7 @@ export const layerQueryRunner: Layer.Layer<
),
assertComplete: Effect.void,
});
return runner;
}),
);

Expand Down Expand Up @@ -12266,12 +12312,46 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
...(input.runtimePolicy.cwd === null ? {} : { dir: input.runtimePolicy.cwd }),
...(upToMessageId === undefined ? {} : { upToMessageId }),
};
const sourceGoal = forkInput.sourceProviderThread.goal;
const launchArgSettings =
sourceGoal == null
? undefined
: yield* readClaudeLaunchArgSettings({
settings: adapterOptions.settings,
cwd: input.runtimePolicy.cwd,
fileSystem,
path,
});
const forked = yield* queryRunner.forkSession({
sessionId: sourceNativeThreadId,
options: forkOptions,
environment: adapterOptions.environment,
threadId: forkInput.targetThreadId,
providerSessionId: input.providerSessionId,
// A fork starts without its source's goal, as Codex forks do. "complete" can
// also mean Claude's Stop-hook cap ended the turn with the goal still armed.
...(sourceGoal == null
? {}
: {
clearGoal: (nativeThreadId: string) => {
const options = makeClaudeQueryOptions({
modelSelection: forkInput.modelSelection ?? input.modelSelection,
modelCatalog,
nativeThreadId,
resume: true,
cwd: input.runtimePolicy.cwd,
settings: adapterOptions.settings,
...(launchArgSettings === undefined
? {}
: { sdkSettings: claudeSdkLaunchArgSettings(launchArgSettings) }),
environment: adapterOptions.environment,
tools: [],
});
return options.permissionMode === "bypassPermissions"
? { ...options, allowDangerouslySkipPermissions: true }
: options;
},
}),
});
yield* Ref.update(openedNativeThreads, (current) => {
const updated = new Set(current);
Expand Down
Loading