diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d7d03c779b4bb..7bc59e6cd8ec7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -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", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index df80f1316fb77..f664f0fa86b80 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -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 { @@ -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, @@ -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; @@ -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) { @@ -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) { @@ -1657,6 +1702,7 @@ export const layerQueryRunner: Layer.Layer< ), assertComplete: Effect.void, }); + return runner; }), ); @@ -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);