run.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. /**
  2. * One-shot Claude Code lifecycle: invoke the official Agent SDK, place its
  3. * real CLI process under the shared subprocess owner, map only strict SDK
  4. * success to completion, and dispose to whole-tree quiescence.
  5. *
  6. * @module @deepseek-ai/dsh-subagent-claude-code/run
  7. */
  8. import { randomUUID } from 'node:crypto'
  9. import {
  10. query as officialQuery,
  11. type Options,
  12. type Query,
  13. type SDKMessage,
  14. type SDKResultMessage,
  15. type SpawnOptions,
  16. } from '@anthropic-ai/claude-agent-sdk'
  17. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  18. import { SessionId } from '@deepseek-ai/dsh-session'
  19. import {
  20. settleRunResult,
  21. subprocessRunHandle,
  22. type SubagentResult,
  23. type SubagentRun,
  24. type SubagentStartRequest,
  25. type SubagentStopReason,
  26. } from '@deepseek-ai/dsh-subagent'
  27. import {
  28. scrubbedParentEnv,
  29. type SubprocessHandle,
  30. type SubprocessSpawnSpec,
  31. } from '@deepseek-ai/dsh-subprocess'
  32. import {
  33. claudeSpawnSpec,
  34. ManagedClaudeCodeProcess,
  35. } from './process.ts'
  36. /** Default POSIX grace between subprocess termination tiers. */
  37. export const DEFAULT_DISPOSE_GRACE_MS = 3_000
  38. /* jscpd:ignore-start -- sibling providers intentionally keep product-private
  39. * run inputs and error normalization instead of adding a shared lifecycle owner. */
  40. /** Fully resolved inputs for one official Claude Agent SDK query. */
  41. export interface ClaudeCodeRunSpec {
  42. /** Parent Session workspace supplied to the SDK and real CLI. */
  43. readonly cwd: string
  44. /** Explicit deployment/test environment layered after shared scrubbing. */
  45. readonly env: Record<string, string>
  46. /** Subprocess termination grace passed to the shared process-tree owner. */
  47. readonly disposeGraceMs: number
  48. /** Shared subprocess service spawn operation. */
  49. readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
  50. /** Diagnostic sink for a post-publication error flattened into a result. */
  51. readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
  52. }
  53. function thrown(value: unknown): Error {
  54. /* v8 ignore next -- typed SDK and subprocess failures reject with Error. */
  55. return value instanceof Error ? value : new Error(String(value))
  56. }
  57. /* jscpd:ignore-end */
  58. /**
  59. * Validate and preserve the one-shot task before crossing the SDK boundary.
  60. * @param prompt - task content accepted from the shared subagent service.
  61. * @returns the exact text sequence as one SDK prompt.
  62. */
  63. export function textTask(prompt: readonly ContentBlock[]): string {
  64. if (prompt.length === 0) {
  65. throw new Error('subagent-claude-code: the one-shot task must contain only text blocks')
  66. }
  67. const texts: string[] = []
  68. for (const block of prompt) {
  69. if (block.type !== 'text') {
  70. throw new Error('subagent-claude-code: the one-shot task must contain only text blocks')
  71. }
  72. texts.push(block.text)
  73. }
  74. if (texts.every(text => text.trim().length === 0)) {
  75. throw new Error('subagent-claude-code: the one-shot task must not be empty')
  76. }
  77. return texts.join('')
  78. }
  79. /**
  80. * Strictly derive the only SDK result that can complete a shared run.
  81. * @param message - an official discriminated result union.
  82. * @returns exact final text for a successful, non-error result.
  83. */
  84. export function successfulResult(message: SDKResultMessage): string {
  85. if (
  86. message.subtype !== 'success'
  87. || message.is_error
  88. || message.result.trim().length === 0
  89. ) {
  90. const detail = message.subtype === 'success'
  91. ? 'success result was marked as an error or contained no answer'
  92. : message.errors.join('; ') || message.subtype
  93. throw new Error(`subagent-claude-code: Claude Code failed: ${detail}`)
  94. }
  95. return message.result
  96. }
  97. /**
  98. * Consume the complete SDK stream and require one strict success plus normal
  99. * iterator completion.
  100. * @param query - published official SDK query.
  101. * @returns the completed shared result.
  102. */
  103. export async function consumeClaudeQuery(
  104. query: AsyncIterable<SDKMessage>,
  105. ): Promise<SubagentResult> {
  106. let answer: string | undefined
  107. for await (const message of query) {
  108. if (message.type !== 'result') continue
  109. answer = successfulResult(message)
  110. }
  111. if (answer === undefined) {
  112. throw new Error('subagent-claude-code: Claude Code ended without a result')
  113. }
  114. return {
  115. output: [{ type: 'text', text: answer }],
  116. stopReason: 'completed',
  117. }
  118. }
  119. /**
  120. * Close the official query, terminate the managed process tree, and wait for
  121. * the subprocess owner to prove it is gone.
  122. * @param query - official SDK query, when creation reached that point.
  123. * @param child - shared-service handle that owns the CLI process tree.
  124. */
  125. export async function disposeClaudeCodeChild(
  126. query: Pick<Query, 'close'> | undefined,
  127. child: SubprocessHandle,
  128. ): Promise<void> {
  129. const failures: Error[] = []
  130. try {
  131. query?.close()
  132. } catch (error: unknown) {
  133. failures.push(thrown(error))
  134. }
  135. if (child.pid > 0) {
  136. child.terminate()
  137. try {
  138. await child.waitForExit()
  139. } catch (error: unknown) {
  140. failures.push(thrown(error))
  141. }
  142. }
  143. try {
  144. await child.done
  145. } catch (error: unknown) {
  146. failures.push(thrown(error))
  147. }
  148. const firstFailure = failures[0]
  149. if (failures.length === 1 && firstFailure !== undefined) throw firstFailure
  150. if (failures.length > 1) {
  151. throw new AggregateError(
  152. failures,
  153. 'subagent-claude-code: query and process cleanup failed',
  154. )
  155. }
  156. }
  157. /**
  158. * Build the fixed official SDK options for one one-shot provider run.
  159. * @param spec - workspace, environment, process seam, and disposal policy.
  160. * @param controller - per-run cancellation owner.
  161. * @param capture - receives the real managed child synchronously from the SDK hook.
  162. * @returns options that inherit native settings while disabling persistence and user questions.
  163. */
  164. export function claudeQueryOptions(
  165. spec: ClaudeCodeRunSpec,
  166. controller: AbortController,
  167. capture: (child: SubprocessHandle) => void,
  168. ): Options {
  169. return {
  170. abortController: controller,
  171. cwd: spec.cwd,
  172. env: { ...scrubbedParentEnv(), ...spec.env },
  173. persistSession: false,
  174. disallowedTools: ['AskUserQuestion'],
  175. spawnClaudeCodeProcess: (options: SpawnOptions) => {
  176. const child = spec.spawn(claudeSpawnSpec(options, spec.disposeGraceMs))
  177. capture(child)
  178. return new ManagedClaudeCodeProcess(child)
  179. },
  180. }
  181. }
  182. /**
  183. * Start one official Claude Agent SDK query and publish its one-shot run.
  184. * @param request - resolved shared subagent request.
  185. * @param spec - workspace, environment, process seam, and diagnostic policy.
  186. * @returns the published run after both Query and real CLI handle exist.
  187. */
  188. export async function startClaudeCodeRun(
  189. request: SubagentStartRequest,
  190. spec: ClaudeCodeRunSpec,
  191. ): Promise<SubagentRun> {
  192. const prompt = textTask(request.prompt)
  193. if (request.signal.aborted) {
  194. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  195. }
  196. const controller = new AbortController()
  197. const requestCancel = (): void => {
  198. if (!controller.signal.aborted) {
  199. controller.abort(new Error('subagent-claude-code: run cancelled locally'))
  200. }
  201. }
  202. const onAbort = (): void => { requestCancel() }
  203. request.signal.addEventListener('abort', onAbort, { once: true })
  204. let child: SubprocessHandle | undefined
  205. let query: Query | undefined
  206. try {
  207. query = officialQuery({
  208. prompt,
  209. options: claudeQueryOptions(spec, controller, (captured) => {
  210. child = captured
  211. }),
  212. })
  213. if (child === undefined || child.pid <= 0) {
  214. throw new Error(
  215. 'subagent-claude-code: official SDK did not publish a controllable Claude Code process',
  216. )
  217. }
  218. if (controller.signal.aborted) {
  219. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  220. }
  221. } catch (error: unknown) {
  222. request.signal.removeEventListener('abort', onAbort)
  223. const cancelledBeforeCleanup = controller.signal.aborted
  224. requestCancel()
  225. if (child !== undefined) {
  226. try {
  227. await disposeClaudeCodeChild(query, child)
  228. } catch (disposeError: unknown) {
  229. throw new AggregateError(
  230. [thrown(error), thrown(disposeError)],
  231. 'subagent-claude-code: startup failed and CLI cleanup also failed',
  232. )
  233. }
  234. } else if (query !== undefined) {
  235. try {
  236. query.close()
  237. } catch (disposeError: unknown) {
  238. throw new AggregateError(
  239. [thrown(error), thrown(disposeError)],
  240. 'subagent-claude-code: startup failed and query cleanup also failed',
  241. )
  242. }
  243. }
  244. // oxlint-disable-next-line typescript/no-unnecessary-condition -- the request can abort while process cleanup is awaited.
  245. if (cancelledBeforeCleanup || request.signal.aborted) {
  246. throw new Error('subagent-claude-code: request was aborted before SDK startup')
  247. }
  248. throw thrown(error)
  249. }
  250. const publishedQuery = query
  251. const publishedChild = child
  252. const result = settleRunResult({
  253. attempt: () => consumeClaudeQuery(publishedQuery),
  254. collectOutput: () => [],
  255. cancelled: () => controller.signal.aborted,
  256. onError: spec.onError,
  257. signal: request.signal,
  258. onAbort,
  259. })
  260. return subprocessRunHandle({
  261. id: SessionId(randomUUID()),
  262. result,
  263. signal: request.signal,
  264. onAbort,
  265. requestCancel,
  266. teardown: () => disposeClaudeCodeChild(
  267. publishedQuery,
  268. publishedChild,
  269. ),
  270. })
  271. }