diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 26d8a6a3..252b6fa0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,9 +10,9 @@ see [vendor/symphony/SPEC.md](vendor/symphony/SPEC.md). Work Please is a long-running TypeScript daemon that turns issue tracker tasks into autonomous Claude Code agent sessions. It continuously polls an issue tracker (GitHub Projects v2 or Asana), -creates an isolated workspace for each eligible issue, renders a Liquid prompt template, and -launches a Claude Code agent session inside that workspace via the -`@anthropic-ai/claude-agent-sdk`. +renders a Liquid prompt template, and launches a Claude Code agent session via one of two runners: +the `@anthropic-ai/claude-agent-sdk` (local, with an isolated workspace per issue) or +`claude-code-action` (remote, via GitHub Actions `repository_dispatch`). The service is primarily a **scheduler/runner** — it does not perform full ticket management. The orchestrator writes only status labels to GitHub issues. All state transitions, PR @@ -40,7 +40,12 @@ work-please/ # Monorepo root (Bun + Turborepo) │ ├── config.ts # YAML front matter → typed ServiceConfig with env-var resolution │ ├── workflow.ts # WORKFLOW.md parser (YAML front matter + Liquid body) │ ├── prompt-builder.ts # Liquid template rendering (issue → prompt string) -│ ├── agent-runner.ts # Claude Code agent session via @anthropic-ai/claude-agent-sdk +│ ├── agent-runner.ts # Re-export shim for backward compatibility (delegates to runner/) +│ ├── runner/ # Agent runner abstraction +│ │ ├── types.ts # AgentRunner interface, AgentSession, SessionResult +│ │ ├── sdk-runner.ts # SDK runner: local Claude Code via @anthropic-ai/claude-agent-sdk +│ │ ├── code-action-runner.ts # Code Action runner: GitHub Actions via repository_dispatch +│ │ └── index.ts # createRunner() factory — selects runner based on config │ ├── workspace.ts # Per-issue directory management, git worktrees, lifecycle hooks │ ├── server.ts # Optional HTTP dashboard (Bun.serve) and JSON API │ ├── tools.ts # MCP tool server (asana_api, github_graphql) injected into agent @@ -86,10 +91,12 @@ work-please/ # Monorepo root (Bun + Turborepo) ┌───────▼──┐ ┌───▼───┐ ┌─▼──────────┐ │ Tracker │ │Workspace│ │ Agent │ │ Client │ │Manager │ │ Runner │ - │(GitHub/ │ │(create,│ │(claude- │ - │ Asana) │ │ hooks, │ │ agent-sdk) │ - └──────────┘ │worktree│ └────────────┘ - └────────┘ + │(GitHub/ │ │(create,│ │(factory) │ + │ Asana) │ │ hooks, │ ├────────────┤ + └──────────┘ │worktree│ │ SdkRunner │ ← local via claude-agent-sdk + └────────┘ │ CodeAction │ ← remote via GH Actions + │ Runner │ (repository_dispatch + poll) + └────────────┘ ``` ### Startup @@ -116,20 +123,34 @@ Each poll tick executes in order: ### Agent Session Lifecycle +The orchestrator selects a runner via `createRunner(config)` based on `agent.runner` (`sdk` or +`code_action`). + +**SDK Runner (default):** + 1. `createWorkspace()` — Creates or reuses a per-issue directory (or git worktree if issue URL points to a GitHub repo). Runs `after_create` hook on first creation. 2. `runBeforeRunHook()` — Executes the optional `before_run` shell hook. -3. `AppServerClient.startSession()` — Validates workspace path against `workspace.root` - (path traversal prevention) and assigns a local session UUID. No SDK communication occurs - yet — the real session is established when `runTurn()` receives a `system/init` event. -4. `AppServerClient.runTurn()` — Calls `query()` from `@anthropic-ai/claude-agent-sdk` with the - rendered prompt. Translates SDK messages into orchestrator events (`session_started`, - `turn_completed`, `turn_failed`, `notification`). +3. `SdkRunner.startSession()` — Validates workspace path against `workspace.root` + (path traversal prevention) and assigns a local session UUID. +4. `SdkRunner.runTurn()` — Calls `query()` from `@anthropic-ai/claude-agent-sdk` with the + rendered prompt. Translates SDK messages into orchestrator events. Supports multi-turn: after each turn, refreshes issue state; continues if still active and under `max_turns`. 5. `runAfterRunHook()` — Executes the optional `after_run` shell hook. -6. On exit — normal exits schedule a 1s continuation retry; failures schedule exponential backoff - retries up to `max_retry_backoff_ms`. +6. On exit — normal exits schedule a 1s continuation retry; failures schedule exponential backoff. + +**Code Action Runner:** + +1. No local workspace created — execution happens in a GitHub Actions runner. +2. `CodeActionRunner.startSession()` — Returns a virtual session (workspace: null). +3. `CodeActionRunner.runTurn()` — Dispatches a `repository_dispatch` event via GitHub API with the + prompt and issue context in `client_payload` (including `before_run`/`after_run` hooks). + Polls the GitHub Actions API until the triggered run completes, then maps the conclusion + (`success`/`failure`/`cancelled`) to orchestrator events. +4. `CodeActionRunner.stopSession()` — Cancels the in-progress GitHub Actions run. +5. The target repository must have a workflow file listening for `repository_dispatch` events + that uses `anthropics/claude-code-action@v1`. ## Architecture Invariants @@ -182,8 +203,9 @@ for narrowing. - **Runner:** Bun test (Jest-compatible API) - **Pattern:** Unit tests co-located with source files (`*.test.ts` alongside `*.ts`) -- **Mocking:** `AppServerClient` accepts an injectable `queryFn` for testing without the real - Claude CLI. Tracker adapters are tested against mock GraphQL/REST responses. Workspace operations +- **Mocking:** `SdkRunner` accepts an injectable `queryFn` for testing without the real + Claude CLI. `CodeActionRunner` tests mock `globalThis.fetch` for GitHub API responses. + Tracker adapters are tested against mock GraphQL/REST responses. Workspace operations use `spyOn(_git, 'spawnSync')` to mock git commands. - **Commands:** `bun run test` (all), `bun run test:app` (work-please only) diff --git a/apps/work-please/src/agent-runner.ts b/apps/work-please/src/agent-runner.ts index 21dfb39e..5e110cf2 100644 --- a/apps/work-please/src/agent-runner.ts +++ b/apps/work-please/src/agent-runner.ts @@ -1,267 +1,10 @@ -import type { Options } from '@anthropic-ai/claude-agent-sdk' -import type { AgentMessage, Issue, ServiceConfig } from './types' -import { randomUUID } from 'node:crypto' -import { resolve, sep } from 'node:path' -import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk' -import { createToolsMcpServer, getToolSpecs } from './tools' +import type { AgentMessage } from './types' -export interface SessionResult { - turn_id: string - session_id: string -} - -export interface AgentSession { - sessionId: string - workspace: string -} - -type QueryFn = (params: { prompt: string, options?: Options }) => AsyncIterable - -const UUID_PATTERN = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i -const NEWLINE_PATTERN = /[\r\n]/g - -// Minimal discriminated shape for SDK messages received in the for-await loop -interface SdkMsgBase { type: string } -interface SdkMsgInit extends SdkMsgBase { type: 'system', subtype: 'init', session_id: string } -interface SdkMsgSuccess extends SdkMsgBase { type: 'result', subtype: 'success', usage: { input_tokens: number, output_tokens: number } } -interface SdkMsgError extends SdkMsgBase { type: 'result', subtype: string, errors: string[] } -interface SdkMsgRateLimit extends SdkMsgBase { type: 'rate_limit_event', rate_limit_info: unknown } -type SdkMsg = SdkMsgInit | SdkMsgSuccess | SdkMsgError | SdkMsgRateLimit | SdkMsgBase - -export class AppServerClient { - private assignedSessionId: string | null = null - private sessionId: string | null = null - private abortController: AbortController | null = null - private workspace: string - private config: ServiceConfig - private queryFn: QueryFn - private agentEnv: Record | null = null - - constructor(config: ServiceConfig, workspace: string, queryFn: QueryFn = sdkQuery) { - this.config = config - this.workspace = workspace - this.queryFn = queryFn - } - - setAgentEnv(env: Record): void { - this.agentEnv = env - } - - async startSession(sessionId?: string): Promise { - // Reset state unconditionally to prevent stale fields on instance reuse or retry - this.assignedSessionId = null - this.sessionId = null - - if (sessionId !== undefined && !UUID_PATTERN.test(sessionId)) { - const preview = String(sessionId).slice(0, 64).replace(NEWLINE_PATTERN, ' ') - return new Error(`invalid_session_id: expected UUID format, got "${preview}"`) - } - - const validationErr = this.validateWorkspaceCwd() - if (validationErr) - return validationErr - - const id = sessionId ?? randomUUID() - this.assignedSessionId = id - // Set sessionId immediately so runTurn uses options.resume (cross-restart resume path). - // The SDK has NOT confirmed this session — it may reject if the session no longer exists. - this.sessionId = sessionId ?? null - return { sessionId: id, workspace: this.workspace } - } - - async runTurn( - session: AgentSession, - prompt: string, - _issue: Issue, - onMessage: (msg: AgentMessage) => void, - ): Promise { - const controller = new AbortController() - this.abortController = controller - - const timeoutHandle = setTimeout( - () => controller.abort(new Error('turn_timeout')), - this.config.claude.turn_timeout_ms, - ) - - const options: Options = { - cwd: session.workspace, - permissionMode: this.config.claude.permission_mode as Options['permissionMode'], - abortController: controller, - } - - if (this.config.claude.permission_mode === 'bypassPermissions') { - options.allowDangerouslySkipPermissions = true - } - - if (this.config.claude.allowed_tools.length > 0) { - options.allowedTools = this.config.claude.allowed_tools - } - - if (this.sessionId) { - options.resume = this.sessionId - } - else if (this.assignedSessionId) { - options.sessionId = this.assignedSessionId as `${string}-${string}-${string}-${string}-${string}` - } - - if (this.config.claude.command !== 'claude') { - options.pathToClaudeCodeExecutable = this.config.claude.command - } - - if (this.config.claude.model) { - options.model = this.config.claude.model - } - - const sp = this.config.claude.system_prompt - if (sp.type === 'custom') { - options.systemPrompt = sp.value - } - else { - options.systemPrompt = sp - } - - options.effort = this.config.claude.effort - - const toolSpecs = getToolSpecs(this.config) - if (toolSpecs.length > 0) { - options.mcpServers = { - 'work-please-tools': createToolsMcpServer(this.config), - } - } - - if (this.config.claude.setting_sources.length > 0) { - options.settingSources = this.config.claude.setting_sources - } - - if (this.agentEnv) { - options.env = this.agentEnv - } - - const turnId = randomUUID() - let sessionId: string | null = null - let gotError = false - - try { - const q = this.queryFn({ prompt, options }) - - for await (const rawMsg of q) { - const msg = rawMsg as SdkMsg - if (msg.type === 'system' && (msg as SdkMsgInit).subtype === 'init') { - const initMsg = msg as SdkMsgInit - sessionId = initMsg.session_id - this.sessionId = sessionId - this.assignedSessionId = null // SDK confirmed — proposed ID no longer needed - onMessage({ - event: 'session_started', - timestamp: new Date(), - session_id: sessionId, - turn_id: turnId, - }) - } - else if (msg.type === 'result') { - const resultMsg = msg as SdkMsgSuccess | SdkMsgError - if (resultMsg.subtype === 'success') { - const successMsg = resultMsg as SdkMsgSuccess - onMessage({ - event: 'turn_completed', - timestamp: new Date(), - usage: { - input_tokens: successMsg.usage.input_tokens, - output_tokens: successMsg.usage.output_tokens, - total_tokens: successMsg.usage.input_tokens + successMsg.usage.output_tokens, - }, - }) - } - else { - const errMsg = resultMsg as SdkMsgError - gotError = true - onMessage({ - event: 'turn_failed', - timestamp: new Date(), - payload: { subtype: errMsg.subtype, errors: errMsg.errors }, - }) - } - } - else if (msg.type === 'rate_limit_event') { - const rlMsg = msg as SdkMsgRateLimit - onMessage({ - event: 'notification', - timestamp: new Date(), - rate_limits: rlMsg.rate_limit_info, - }) - } - else { - onMessage({ - event: 'notification', - timestamp: new Date(), - payload: rawMsg, - }) - } - } - - clearTimeout(timeoutHandle) - - if (gotError) { - return new Error('turn_failed') - } - - if (!sessionId) { - const err = new Error('no_session_started') - onMessage({ - event: 'startup_failed', - timestamp: new Date(), - payload: { reason: err.message }, - }) - return err - } - - return { turn_id: turnId, session_id: sessionId } - } - catch (err) { - clearTimeout(timeoutHandle) - const error = err instanceof Error ? err : new Error(String(err)) - // If init was never received, the session never started — report startup_failed - // and clear stale resume state so the next runTurn does not retry a poisoned session. - // If init was already received and the turn was aborted mid-execution, report turn_failed - // so callers can distinguish a startup failure from a mid-turn failure. - if (!sessionId) { - // Preserve resume state on transient pre-init failures so the next turn can retry. - // Only clear state for new sessions where no session was ever confirmed. - if (!options.resume) { - this.sessionId = null - this.assignedSessionId = null - } - } - onMessage({ - event: sessionId ? 'turn_failed' : 'startup_failed', - timestamp: new Date(), - payload: { reason: error.message }, - }) - return error - } - } - - stopSession(): void { - this.abortController?.abort() - this.assignedSessionId = null - this.sessionId = null - this.abortController = null - } - - private validateWorkspaceCwd(): Error | null { - const wsPath = resolve(this.workspace) - const root = resolve(this.config.workspace.root) - const rootWithSep = root + sep - - if (wsPath === root) - return new Error(`invalid_workspace_cwd: workspace_root ${wsPath}`) - if (!wsPath.startsWith(rootWithSep)) - return new Error(`invalid_workspace_cwd: outside_workspace_root ${wsPath}`) - return null - } -} +// Re-export SdkRunner as AppServerClient for backward compatibility +export { SdkRunner as AppServerClient } from './runner/sdk-runner' +export type { AgentRunner, AgentSession, SessionResult } from './runner/types' -// Utility exports (kept for backward compatibility with tests and orchestrator) +// Utility exports (used by tests and orchestrator) type JsonRpcMessage = Record export function extractRateLimits(payload: JsonRpcMessage): { rate_limits?: unknown } { diff --git a/apps/work-please/src/config.test.ts b/apps/work-please/src/config.test.ts index 29bb8d09..546b7450 100644 --- a/apps/work-please/src/config.test.ts +++ b/apps/work-please/src/config.test.ts @@ -792,3 +792,109 @@ describe('buildConfig - env section', () => { expect(Object.keys(config.env)).toHaveLength(2) }) }) + +describe('agent.runner config', () => { + it('defaults runner to sdk', () => { + const config = buildConfig(makeWorkflow({})) + expect(config.agent.runner).toBe('sdk') + }) + + it('parses code_action runner', () => { + const config = buildConfig(makeWorkflow({ agent: { runner: 'code_action' } })) + expect(config.agent.runner).toBe('code_action') + }) + + it('treats unknown runner value as sdk', () => { + const config = buildConfig(makeWorkflow({ agent: { runner: 'unknown' } })) + expect(config.agent.runner).toBe('sdk') + }) +}) + +describe('code_action config', () => { + it('applies defaults when section is missing', () => { + const config = buildConfig(makeWorkflow({})) + expect(config.code_action.workflow_file).toBe('.github/workflows/claude.yml') + expect(config.code_action.ref).toBe('main') + expect(config.code_action.event_type).toBe('claude-code-action') + expect(config.code_action.poll_interval_ms).toBe(10_000) + expect(config.code_action.timeout_ms).toBe(3_600_000) + expect(config.code_action.repository).toBeNull() + }) + + it('parses code_action section', () => { + const config = buildConfig(makeWorkflow({ + code_action: { + repository: 'myorg/my-repo', + workflow_file: '.github/workflows/custom.yml', + ref: 'develop', + event_type: 'custom-event', + poll_interval_ms: 15000, + timeout_ms: 1800000, + github_token: 'ghp_test', + }, + })) + expect(config.code_action.repository).toBe('myorg/my-repo') + expect(config.code_action.workflow_file).toBe('.github/workflows/custom.yml') + expect(config.code_action.ref).toBe('develop') + expect(config.code_action.event_type).toBe('custom-event') + expect(config.code_action.poll_interval_ms).toBe(15000) + expect(config.code_action.timeout_ms).toBe(1800000) + expect(config.code_action.github_token).toBe('ghp_test') + }) + + it('resolves github_token from env var', () => { + const prev = process.env.CODE_ACTION_TOKEN + process.env.CODE_ACTION_TOKEN = 'from-env' + try { + const config = buildConfig(makeWorkflow({ + code_action: { github_token: '$CODE_ACTION_TOKEN' }, + })) + expect(config.code_action.github_token).toBe('from-env') + } + finally { + if (prev === undefined) + delete process.env.CODE_ACTION_TOKEN + else process.env.CODE_ACTION_TOKEN = prev + } + }) +}) + +describe('validateConfig code_action', () => { + it('rejects code_action runner without github_token', () => { + const config = buildConfig(makeWorkflow({ + tracker: { kind: 'github_projects', api_key: 'token', owner: 'org', project_number: 1 }, + agent: { runner: 'code_action' }, + code_action: { repository: 'org/repo' }, + })) + config.code_action.github_token = null + expect(validateConfig(config)?.code).toBe('missing_code_action_github_token') + }) + + it('rejects code_action runner without repository', () => { + const config = buildConfig(makeWorkflow({ + tracker: { kind: 'github_projects', api_key: 'token', owner: 'org', project_number: 1 }, + agent: { runner: 'code_action' }, + code_action: { github_token: 'ghp_test' }, + })) + expect(validateConfig(config)?.code).toBe('missing_code_action_repository') + }) + + it('accepts valid code_action config', () => { + const config = buildConfig(makeWorkflow({ + tracker: { kind: 'github_projects', api_key: 'token', owner: 'org', project_number: 1 }, + agent: { runner: 'code_action' }, + code_action: { repository: 'org/repo', github_token: 'ghp_test' }, + })) + expect(validateConfig(config)).toBeNull() + }) + + it('skips claude command check for code_action runner', () => { + const config = buildConfig(makeWorkflow({ + tracker: { kind: 'github_projects', api_key: 'token', owner: 'org', project_number: 1 }, + agent: { runner: 'code_action' }, + code_action: { repository: 'org/repo', github_token: 'ghp_test' }, + })) + config.claude.command = '' + expect(validateConfig(config)).toBeNull() + }) +}) diff --git a/apps/work-please/src/config.ts b/apps/work-please/src/config.ts index 93ebcd97..ae13a24c 100644 --- a/apps/work-please/src/config.ts +++ b/apps/work-please/src/config.ts @@ -1,4 +1,4 @@ -import type { ClaudeEffort, IssueFilter, ServiceConfig, SettingSource, SystemPromptConfig, WorkflowDefinition } from './types' +import type { ClaudeEffort, CodeActionConfig, IssueFilter, ServiceConfig, SettingSource, SystemPromptConfig, WorkflowDefinition } from './types' import { tmpdir } from 'node:os' import { join, sep } from 'node:path' import process from 'node:process' @@ -31,6 +31,11 @@ const DEFAULTS = { GITHUB_TERMINAL_STATUSES: ['Closed', 'Cancelled', 'Canceled', 'Duplicate', 'Done'] as string[], GITHUB_WATCHED_STATUSES: ['Human Review'] as string[], ASANA_WATCHED_SECTIONS: [] as string[], + CODE_ACTION_WORKFLOW_FILE: '.github/workflows/claude.yml', + CODE_ACTION_REF: 'main', + CODE_ACTION_EVENT_TYPE: 'claude-code-action', + CODE_ACTION_POLL_INTERVAL_MS: 10_000, + CODE_ACTION_TIMEOUT_MS: 3_600_000, } export function buildConfig(workflow: WorkflowDefinition): ServiceConfig { @@ -61,12 +66,14 @@ export function buildConfig(workflow: WorkflowDefinition): ServiceConfig { timeout_ms: posIntValue(hooks.timeout_ms, DEFAULTS.HOOK_TIMEOUT_MS), }, agent: { + runner: runnerValue(agent.runner), max_concurrent_agents: intValue(agent.max_concurrent_agents, DEFAULTS.MAX_CONCURRENT_AGENTS), max_turns: posIntValue(agent.max_turns, DEFAULTS.AGENT_MAX_TURNS), max_retry_backoff_ms: posIntValue(agent.max_retry_backoff_ms, DEFAULTS.MAX_RETRY_BACKOFF_MS), max_concurrent_agents_by_state: stateLimitsValue(agent.max_concurrent_agents_by_state), }, claude: buildClaudeConfig(claude), + code_action: buildCodeActionConfig(sectionMap(raw, 'code_action')), env: buildEnvConfig(raw), server: { port: nonNegIntOrNull(server.port), @@ -98,6 +105,18 @@ function buildClaudeConfig(claude: Record): ServiceConfig['clau } } +function buildCodeActionConfig(raw: Record): CodeActionConfig { + return { + repository: stringValue(raw.repository), + workflow_file: stringValue(raw.workflow_file) ?? DEFAULTS.CODE_ACTION_WORKFLOW_FILE, + ref: stringValue(raw.ref) ?? DEFAULTS.CODE_ACTION_REF, + event_type: stringValue(raw.event_type) ?? DEFAULTS.CODE_ACTION_EVENT_TYPE, + poll_interval_ms: posIntValue(raw.poll_interval_ms, DEFAULTS.CODE_ACTION_POLL_INTERVAL_MS), + timeout_ms: posIntValue(raw.timeout_ms, DEFAULTS.CODE_ACTION_TIMEOUT_MS), + github_token: resolveEnvValue(stringValue(raw.github_token), process.env.GITHUB_TOKEN), + } +} + function buildTrackerConfig(kind: string | null, tracker: Record): ServiceConfig['tracker'] { const label_prefix = stringValue(tracker.label_prefix) ?? null const filter = buildFilterConfig(sectionMap(tracker, 'filter')) @@ -158,6 +177,8 @@ export type ValidationError | { code: 'incomplete_github_app_config', missing: string[] } | { code: 'missing_tracker_project_config', field: string } | { code: 'missing_claude_command' } + | { code: 'missing_code_action_github_token' } + | { code: 'missing_code_action_repository' } export function validateConfig(config: ServiceConfig): ValidationError | null { const { kind } = config.tracker @@ -204,8 +225,16 @@ export function validateConfig(config: ServiceConfig): ValidationError | null { } } - if (!config.claude.command.trim()) - return { code: 'missing_claude_command' } + if (config.agent.runner === 'code_action') { + if (!config.code_action.github_token) + return { code: 'missing_code_action_github_token' } + if (!config.code_action.repository) + return { code: 'missing_code_action_repository' } + } + else { + if (!config.claude.command.trim()) + return { code: 'missing_claude_command' } + } return null } @@ -462,6 +491,13 @@ function buildEnvConfig(raw: Record): Record { return result } +function runnerValue(val: unknown): 'sdk' | 'code_action' { + const s = typeof val === 'string' ? val.trim().toLowerCase() : null + if (s === 'code_action') + return 'code_action' + return 'sdk' +} + function normalizeTrackerKind(kind: string | null): string | null { if (!kind) return null diff --git a/apps/work-please/src/label.test.ts b/apps/work-please/src/label.test.ts index e33a29a1..8a742d4e 100644 --- a/apps/work-please/src/label.test.ts +++ b/apps/work-please/src/label.test.ts @@ -38,8 +38,9 @@ function makeGithubConfig(labelPrefix: string | null): ServiceConfig { polling: { interval_ms: 30000 }, workspace: { root: '/tmp' }, hooks: { after_create: null, before_run: null, after_run: null, before_remove: null, timeout_ms: 60000 }, - agent: { max_concurrent_agents: 5, max_turns: 20, max_retry_backoff_ms: 300000, max_concurrent_agents_by_state: {} }, + agent: { runner: 'sdk', max_concurrent_agents: 5, max_turns: 20, max_retry_backoff_ms: 300000, max_concurrent_agents_by_state: {} }, claude: { model: null, effort: 'high' as const, command: 'claude', permission_mode: 'bypassPermissions', allowed_tools: [], setting_sources: [], turn_timeout_ms: 3600000, read_timeout_ms: 5000, stall_timeout_ms: 300000, system_prompt: { type: 'preset', preset: 'claude_code' }, settings: { attribution: { commit: null, pr: null } } }, + code_action: { repository: null, workflow_file: '.github/workflows/claude.yml', ref: 'main', event_type: 'claude-code-action', poll_interval_ms: 10000, timeout_ms: 3600000, github_token: null }, env: {}, server: { port: null }, } @@ -58,8 +59,9 @@ function makeAsanaConfig(): ServiceConfig { polling: { interval_ms: 30000 }, workspace: { root: '/tmp' }, hooks: { after_create: null, before_run: null, after_run: null, before_remove: null, timeout_ms: 60000 }, - agent: { max_concurrent_agents: 5, max_turns: 20, max_retry_backoff_ms: 300000, max_concurrent_agents_by_state: {} }, + agent: { runner: 'sdk', max_concurrent_agents: 5, max_turns: 20, max_retry_backoff_ms: 300000, max_concurrent_agents_by_state: {} }, claude: { model: null, effort: 'high' as const, command: 'claude', permission_mode: 'bypassPermissions', allowed_tools: [], setting_sources: [], turn_timeout_ms: 3600000, read_timeout_ms: 5000, stall_timeout_ms: 300000, system_prompt: { type: 'preset', preset: 'claude_code' }, settings: { attribution: { commit: null, pr: null } } }, + code_action: { repository: null, workflow_file: '.github/workflows/claude.yml', ref: 'main', event_type: 'claude-code-action', poll_interval_ms: 10000, timeout_ms: 3600000, github_token: null }, env: {}, server: { port: null }, } diff --git a/apps/work-please/src/orchestrator.ts b/apps/work-please/src/orchestrator.ts index d3a77e31..40afc5f2 100644 --- a/apps/work-please/src/orchestrator.ts +++ b/apps/work-please/src/orchestrator.ts @@ -3,10 +3,10 @@ import type { TrackerAdapter } from './tracker/types' import type { Issue, OrchestratorState, RetryEntry, RunningEntry, ServiceConfig, WorkflowDefinition } from './types' import { watch } from 'node:fs' import { resolveAgentEnv } from './agent-env' -import { AppServerClient } from './agent-runner' import { buildConfig, getActiveStates, getTerminalStates, getWatchedStates, maxConcurrentForState, normalizeState, validateConfig } from './config' import { createLabelService } from './label' import { buildContinuationPrompt, buildPrompt, isPromptBuildError } from './prompt-builder' +import { createRunner } from './runner' import { createTrackerAdapter, formatTrackerError, isTrackerError } from './tracker/index' import { isWorkflowError, loadWorkflow } from './workflow' import { createWorkspace, removeWorkspace, runAfterRunHook, runBeforeRunHook } from './workspace' @@ -240,39 +240,47 @@ export class Orchestrator { } private async executeAgentRun(issue: Issue, attempt: number | null): Promise { - // Create/reuse workspace - const wsResult = await createWorkspace(this.config, issue.identifier, issue) - if (wsResult instanceof Error) { - throw wsResult - } + const isCodeAction = this.config.agent.runner === 'code_action' + + // Create workspace only for SDK runner + let wsPath: string | null = null + if (!isCodeAction) { + const wsResult = await createWorkspace(this.config, issue.identifier, issue) + if (wsResult instanceof Error) { + throw wsResult + } + wsPath = wsResult.path - // Before-run hook - const beforeRunErr = await runBeforeRunHook(this.config, wsResult.path, issue) - if (beforeRunErr) { - await runAfterRunHook(this.config, wsResult.path, issue) - throw beforeRunErr + // Before-run hook (SDK runner only — code_action passes hooks via client_payload) + const beforeRunErr = await runBeforeRunHook(this.config, wsPath, issue) + if (beforeRunErr) { + await runAfterRunHook(this.config, wsPath, issue) + throw beforeRunErr + } } // Resolve agent environment variables (including runtime tokens) - const client = new AppServerClient(this.config, wsResult.path) + const runner = createRunner(this.config, wsPath) const agentEnv = await resolveAgentEnv(this.config, this.buildTokenProvider()) - client.setAgentEnv(agentEnv) + runner.setAgentEnv(agentEnv) // Start agent session - const session = await client.startSession() + const session = await runner.startSession() if (session instanceof Error) { - await runAfterRunHook(this.config, wsResult.path, issue) + if (wsPath) + await runAfterRunHook(this.config, wsPath, issue) throw session } try { // Resolve project status field metadata for prompt context await this.populateProjectContext(issue) - await this.runAgentTurns(client, session, issue, attempt) + await this.runAgentTurns(runner, session, issue, attempt) } finally { - client.stopSession() - await runAfterRunHook(this.config, wsResult.path, issue) + runner.stopSession() + if (wsPath) + await runAfterRunHook(this.config, wsPath, issue) } } @@ -298,8 +306,8 @@ export class Orchestrator { } private async runAgentTurns( - client: AppServerClient, - session: import('./agent-runner').AgentSession, + client: import('./runner/types').AgentRunner, + session: import('./runner/types').AgentSession, issue: Issue, attempt: number | null, ): Promise { diff --git a/apps/work-please/src/runner/code-action-runner.test.ts b/apps/work-please/src/runner/code-action-runner.test.ts new file mode 100644 index 00000000..52adf4e4 --- /dev/null +++ b/apps/work-please/src/runner/code-action-runner.test.ts @@ -0,0 +1,232 @@ +import type { AgentMessage, CodeActionConfig, Issue, ServiceConfig } from '../types' +import type { AgentSession } from './types' +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { CodeActionRunner } from './code-action-runner' + +const DEFAULT_CODE_ACTION: CodeActionConfig = { + repository: 'myorg/my-repo', + workflow_file: '.github/workflows/claude.yml', + ref: 'main', + event_type: 'claude-code-action', + poll_interval_ms: 50, // fast for tests + timeout_ms: 2000, + github_token: 'ghp_test_token', +} + +function makeConfig(overrides: Partial = {}): ServiceConfig { + return { + tracker: { kind: 'github_projects', endpoint: 'https://api.github.com', api_key: 'token', owner: 'myorg', project_number: 1, label_prefix: null, filter: { assignee: [], label: [] } }, + polling: { interval_ms: 30000 }, + workspace: { root: '/tmp' }, + hooks: { after_create: null, before_run: null, after_run: null, before_remove: null, timeout_ms: 60000 }, + agent: { runner: 'code_action', max_concurrent_agents: 5, max_turns: 1, max_retry_backoff_ms: 300000, max_concurrent_agents_by_state: {} }, + claude: { model: null, effort: 'high' as const, command: 'claude', permission_mode: 'bypassPermissions', allowed_tools: [], setting_sources: [], turn_timeout_ms: 3600000, read_timeout_ms: 5000, stall_timeout_ms: 300000, system_prompt: { type: 'preset', preset: 'claude_code' }, settings: { attribution: { commit: null, pr: null } } }, + code_action: { ...DEFAULT_CODE_ACTION, ...overrides }, + env: {}, + server: { port: null }, + } +} + +function makeIssue(overrides: Partial = {}): Issue { + return { + id: 'issue-1', + identifier: '#42', + title: 'Test issue', + description: 'A test issue', + priority: null, + state: 'In Progress', + branch_name: null, + url: 'https://github.com/myorg/my-repo/issues/42', + assignees: [], + labels: [], + blocked_by: [], + pull_requests: [], + review_decision: null, + created_at: null, + updated_at: null, + project: null, + ...overrides, + } +} + +// Helper to create a mock fetch that responds to specific API calls +function createMockFetch(responses: Array<{ url: string | RegExp, method?: string, status: number, body?: unknown }>) { + const calls: Array<{ url: string, init?: RequestInit }> = [] + return { + calls, + fn: mock((url: string | URL, init?: RequestInit) => { + const urlStr = String(url) + const method = init?.method ?? 'GET' + calls.push({ url: urlStr, init }) + const match = responses.find((r) => { + const urlMatch = typeof r.url === 'string' ? urlStr.includes(r.url) : r.url.test(urlStr) + if (!urlMatch) + return false + return !r.method || r.method === method + }) + if (!match) { + return Promise.resolve(new Response(JSON.stringify({ message: 'Not Found' }), { status: 404 })) + } + return Promise.resolve(new Response(JSON.stringify(match.body ?? {}), { status: match.status })) + }), + } +} + +describe('CodeActionRunner', () => { + let originalFetch: typeof globalThis.fetch + + beforeEach(() => { + originalFetch = globalThis.fetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + describe('startSession', () => { + it('returns a virtual session with null workspace', async () => { + const runner = new CodeActionRunner(makeConfig()) + const session = await runner.startSession() + expect(session).not.toBeInstanceOf(Error) + const s = session as AgentSession + expect(s.workspace).toBeNull() + expect(s.sessionId).toBeDefined() + }) + + it('accepts a provided session ID', async () => { + const runner = new CodeActionRunner(makeConfig()) + const session = await runner.startSession('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + expect(session).not.toBeInstanceOf(Error) + expect((session as AgentSession).sessionId).toBe('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + }) + + it('rejects missing github_token', async () => { + const runner = new CodeActionRunner(makeConfig({ github_token: null })) + const session = await runner.startSession() + expect(session).toBeInstanceOf(Error) + expect((session as Error).message).toContain('github_token') + }) + + it('rejects missing repository', async () => { + const runner = new CodeActionRunner(makeConfig({ repository: null })) + const session = await runner.startSession() + expect(session).toBeInstanceOf(Error) + expect((session as Error).message).toContain('repository') + }) + }) + + describe('runTurn', () => { + it('dispatches repository_dispatch and polls until success', async () => { + const mockFetch = createMockFetch([ + // 1. Dispatch + { url: '/dispatches', status: 204 }, + // 2. Find run (first poll returns empty, second finds it) + { url: /\/actions\/workflows\/.*\/runs\?/, status: 200, body: { workflow_runs: [{ id: 12345, status: 'in_progress', conclusion: null }] } }, + // 3. Poll run status - completed + { url: '/actions/runs/12345', status: 200, body: { id: 12345, status: 'completed', conclusion: 'success' } }, + ]) + globalThis.fetch = mockFetch.fn as unknown as typeof fetch + + const runner = new CodeActionRunner(makeConfig()) + const session = (await runner.startSession()) as AgentSession + const messages: AgentMessage[] = [] + + const result = await runner.runTurn(session, 'Fix the bug', makeIssue(), msg => messages.push(msg)) + + expect(result).not.toBeInstanceOf(Error) + expect(messages.some(m => m.event === 'session_started')).toBe(true) + expect(messages.some(m => m.event === 'turn_completed')).toBe(true) + + // Verify dispatch payload + const dispatchCall = mockFetch.calls.find(c => c.url.includes('/dispatches')) + expect(dispatchCall).toBeDefined() + const body = JSON.parse(dispatchCall!.init?.body as string) + expect(body.event_type).toBe('claude-code-action') + expect(body.client_payload.prompt).toBe('Fix the bug') + expect(body.client_payload.issue_identifier).toBe('#42') + }) + + it('emits turn_failed when workflow fails', async () => { + const mockFetch = createMockFetch([ + { url: '/dispatches', status: 204 }, + { url: /\/actions\/workflows\/.*\/runs\?/, status: 200, body: { workflow_runs: [{ id: 99, status: 'completed', conclusion: 'failure' }] } }, + { url: '/actions/runs/99', status: 200, body: { id: 99, status: 'completed', conclusion: 'failure' } }, + ]) + globalThis.fetch = mockFetch.fn as unknown as typeof fetch + + const runner = new CodeActionRunner(makeConfig()) + const session = (await runner.startSession()) as AgentSession + const messages: AgentMessage[] = [] + + const result = await runner.runTurn(session, 'Prompt', makeIssue(), msg => messages.push(msg)) + + expect(result).toBeInstanceOf(Error) + expect(messages.some(m => m.event === 'turn_failed')).toBe(true) + }) + + it('returns error when dispatch fails', async () => { + const mockFetch = createMockFetch([ + { url: '/dispatches', status: 422, body: { message: 'Validation Failed' } }, + ]) + globalThis.fetch = mockFetch.fn as unknown as typeof fetch + + const runner = new CodeActionRunner(makeConfig()) + const session = (await runner.startSession()) as AgentSession + const messages: AgentMessage[] = [] + + const result = await runner.runTurn(session, 'Prompt', makeIssue(), msg => messages.push(msg)) + + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toContain('dispatch_failed') + }) + + it('includes hooks in client_payload', async () => { + const config = makeConfig() + config.hooks.before_run = 'echo before' + config.hooks.after_run = 'echo after' + + const mockFetch = createMockFetch([ + { url: '/dispatches', status: 204 }, + { url: /\/actions\/workflows\/.*\/runs\?/, status: 200, body: { workflow_runs: [{ id: 1, status: 'completed', conclusion: 'success' }] } }, + { url: '/actions/runs/1', status: 200, body: { id: 1, status: 'completed', conclusion: 'success' } }, + ]) + globalThis.fetch = mockFetch.fn as unknown as typeof fetch + + const runner = new CodeActionRunner(config) + const session = (await runner.startSession()) as AgentSession + await runner.runTurn(session, 'Prompt', makeIssue(), () => {}) + + const dispatchCall = mockFetch.calls.find(c => c.url.includes('/dispatches')) + const body = JSON.parse(dispatchCall!.init?.body as string) + expect(body.client_payload.before_run).toBe('echo before') + expect(body.client_payload.after_run).toBe('echo after') + }) + }) + + describe('stopSession', () => { + it('cancels in-progress run', async () => { + const mockFetch = createMockFetch([ + { url: '/dispatches', status: 204 }, + { url: /\/actions\/workflows\/.*\/runs\?/, status: 200, body: { workflow_runs: [{ id: 77, status: 'in_progress', conclusion: null }] } }, + // Poll keeps returning in_progress (GET) + { url: '/actions/runs/77', method: 'GET', status: 200, body: { id: 77, status: 'in_progress', conclusion: null } }, + // Cancel (POST) + { url: '/actions/runs/77/cancel', method: 'POST', status: 202 }, + ]) + globalThis.fetch = mockFetch.fn as unknown as typeof fetch + + const runner = new CodeActionRunner(makeConfig({ timeout_ms: 200 })) + const session = (await runner.startSession()) as AgentSession + + // Start runTurn in background, then cancel + const turnPromise = runner.runTurn(session, 'Prompt', makeIssue(), () => {}) + + // Give it time to dispatch and start polling + await new Promise(resolve => setTimeout(resolve, 100)) + runner.stopSession() + + const result = await turnPromise + expect(result).toBeInstanceOf(Error) + }) + }) +}) diff --git a/apps/work-please/src/runner/code-action-runner.ts b/apps/work-please/src/runner/code-action-runner.ts new file mode 100644 index 00000000..a8efb35d --- /dev/null +++ b/apps/work-please/src/runner/code-action-runner.ts @@ -0,0 +1,288 @@ +import type { AgentMessage, Issue, ServiceConfig } from '../types' +import type { AgentRunner, AgentSession, SessionResult } from './types' +import { randomUUID } from 'node:crypto' + +interface WorkflowRun { + id: number + status: string + conclusion: string | null +} + +export class CodeActionRunner implements AgentRunner { + private config: ServiceConfig + private agentEnv: Record | null = null + private activeRunId: number | null = null + private aborted = false + + constructor(config: ServiceConfig) { + this.config = config + } + + setAgentEnv(env: Record): void { + this.agentEnv = env + } + + async startSession(sessionId?: string): Promise { + const { github_token, repository } = this.config.code_action + if (!github_token) + return new Error('code_action: missing github_token') + if (!repository) + return new Error('code_action: missing repository') + + return { + sessionId: sessionId ?? randomUUID(), + workspace: null, + } + } + + async runTurn( + session: AgentSession, + prompt: string, + issue: Issue, + onMessage: (msg: AgentMessage) => void, + ): Promise { + const { repository, event_type, github_token, poll_interval_ms, timeout_ms } = this.config.code_action + const turnId = randomUUID() + this.aborted = false + this.activeRunId = null + + onMessage({ + event: 'session_started', + timestamp: new Date(), + session_id: session.sessionId, + turn_id: turnId, + }) + + // 1. Dispatch repository_dispatch event + const dispatchErr = await this.dispatch(repository!, event_type, prompt, issue, github_token!) + if (dispatchErr) { + onMessage({ + event: 'startup_failed', + timestamp: new Date(), + payload: { reason: dispatchErr.message }, + }) + return dispatchErr + } + + // 2. Find the triggered run + const dispatchedAt = Date.now() + const run = await this.findRun(repository!, dispatchedAt, github_token!, timeout_ms) + if (this.aborted) + return new Error('cancelled') + if (run instanceof Error) { + onMessage({ + event: 'turn_failed', + timestamp: new Date(), + payload: { reason: run.message }, + }) + return run + } + + this.activeRunId = run.id + + // If already completed, handle immediately + if (run.status === 'completed') { + return this.handleConclusion(run, turnId, session.sessionId, onMessage) + } + + // 3. Poll until completion + const completedRun = await this.pollUntilComplete( + repository!, + run.id, + github_token!, + poll_interval_ms, + timeout_ms, + dispatchedAt, + ) + if (this.aborted) + return new Error('cancelled') + if (completedRun instanceof Error) { + onMessage({ + event: 'turn_failed', + timestamp: new Date(), + payload: { reason: completedRun.message }, + }) + return completedRun + } + + return this.handleConclusion(completedRun, turnId, session.sessionId, onMessage) + } + + stopSession(): void { + this.aborted = true + if (this.activeRunId) { + const { repository, github_token } = this.config.code_action + if (repository && github_token) { + this.cancelRun(repository, this.activeRunId, github_token).catch(() => {}) + } + } + this.activeRunId = null + } + + private handleConclusion( + run: WorkflowRun, + turnId: string, + sessionId: string, + onMessage: (msg: AgentMessage) => void, + ): SessionResult | Error { + if (run.conclusion === 'success') { + onMessage({ event: 'turn_completed', timestamp: new Date() }) + return { turn_id: turnId, session_id: sessionId } + } + onMessage({ + event: 'turn_failed', + timestamp: new Date(), + payload: { conclusion: run.conclusion }, + }) + return new Error(`workflow_${run.conclusion ?? 'unknown'}`) + } + + private async dispatch( + repository: string, + eventType: string, + prompt: string, + issue: Issue, + token: string, + ): Promise { + const url = `https://api.github.com/repos/${repository}/dispatches` + const payload: Record = { + event_type: eventType, + client_payload: { + prompt, + issue_id: issue.id, + issue_identifier: issue.identifier, + issue_title: issue.title, + before_run: this.config.hooks.before_run ?? '', + after_run: this.config.hooks.after_run ?? '', + }, + } + + try { + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + body: JSON.stringify(payload), + }) + if (resp.status !== 204) { + const body = await resp.text().catch(() => '') + return new Error(`dispatch_failed: HTTP ${resp.status} ${body}`) + } + return null + } + catch (err) { + return new Error(`dispatch_failed: ${err instanceof Error ? err.message : String(err)}`) + } + } + + private async findRun( + repository: string, + dispatchedAt: number, + token: string, + timeoutMs: number, + ): Promise { + const deadline = dispatchedAt + timeoutMs + // GH Actions may take a few seconds to register the run + const maxAttempts = Math.ceil(Math.min(timeoutMs, 60_000) / this.config.code_action.poll_interval_ms) + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (this.aborted) + return new Error('cancelled') + + await this.sleep(this.config.code_action.poll_interval_ms) + if (Date.now() > deadline) + return new Error('timeout: run not found') + + const createdFilter = new Date(dispatchedAt - 5000).toISOString() + const workflowFile = encodeURIComponent(this.config.code_action.workflow_file.split('/').pop() ?? this.config.code_action.workflow_file) + const url = `https://api.github.com/repos/${repository}/actions/workflows/${workflowFile}/runs?event=repository_dispatch&per_page=5&created=>=${createdFilter}` + try { + const resp = await fetch(url, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + if (!resp.ok) + continue + + const data = await resp.json() as { workflow_runs: WorkflowRun[] } + const runs = data.workflow_runs ?? [] + if (runs.length > 0) + return runs[0] + } + catch { + // retry + } + } + + return new Error('timeout: run not found') + } + + private async pollUntilComplete( + repository: string, + runId: number, + token: string, + pollIntervalMs: number, + timeoutMs: number, + startedAt: number, + ): Promise { + const deadline = startedAt + timeoutMs + + while (Date.now() < deadline) { + if (this.aborted) + return new Error('cancelled') + + await this.sleep(pollIntervalMs) + + const url = `https://api.github.com/repos/${repository}/actions/runs/${runId}` + try { + const resp = await fetch(url, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + if (!resp.ok) + continue + + const run = await resp.json() as WorkflowRun + if (run.status === 'completed') + return run + } + catch { + // retry + } + } + + // Timeout — cancel the run + await this.cancelRun(repository, runId, token) + return new Error('timeout: workflow did not complete') + } + + private async cancelRun(repository: string, runId: number, token: string): Promise { + const url = `https://api.github.com/repos/${repository}/actions/runs/${runId}/cancel` + try { + await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }) + } + catch { + // best-effort + } + } + + private sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) + } +} diff --git a/apps/work-please/src/runner/index.ts b/apps/work-please/src/runner/index.ts new file mode 100644 index 00000000..1e5fc557 --- /dev/null +++ b/apps/work-please/src/runner/index.ts @@ -0,0 +1,19 @@ +import type { ServiceConfig } from '../types' +import type { AgentRunner } from './types' +import { CodeActionRunner } from './code-action-runner' +import { SdkRunner } from './sdk-runner' + +export function createRunner(config: ServiceConfig, workspace: string | null): AgentRunner { + const runner = config.agent.runner ?? 'sdk' + + if (runner === 'code_action') { + return new CodeActionRunner(config) + } + + if (!workspace) { + throw new Error('SDK runner requires a workspace path') + } + return new SdkRunner(config, workspace) +} + +export type { AgentRunner, AgentSession, SessionResult } from './types' diff --git a/apps/work-please/src/runner/sdk-runner.ts b/apps/work-please/src/runner/sdk-runner.ts new file mode 100644 index 00000000..7f17933d --- /dev/null +++ b/apps/work-please/src/runner/sdk-runner.ts @@ -0,0 +1,253 @@ +import type { Options } from '@anthropic-ai/claude-agent-sdk' +import type { AgentMessage, Issue, ServiceConfig } from '../types' +import type { AgentRunner, AgentSession, SessionResult } from './types' +import { randomUUID } from 'node:crypto' +import { resolve, sep } from 'node:path' +import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk' +import { createToolsMcpServer, getToolSpecs } from '../tools' + +type QueryFn = (params: { prompt: string, options?: Options }) => AsyncIterable + +const UUID_PATTERN = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i +const NEWLINE_PATTERN = /[\r\n]/g + +// Minimal discriminated shape for SDK messages received in the for-await loop +interface SdkMsgBase { type: string } +interface SdkMsgInit extends SdkMsgBase { type: 'system', subtype: 'init', session_id: string } +interface SdkMsgSuccess extends SdkMsgBase { type: 'result', subtype: 'success', usage: { input_tokens: number, output_tokens: number } } +interface SdkMsgError extends SdkMsgBase { type: 'result', subtype: string, errors: string[] } +interface SdkMsgRateLimit extends SdkMsgBase { type: 'rate_limit_event', rate_limit_info: unknown } +type SdkMsg = SdkMsgInit | SdkMsgSuccess | SdkMsgError | SdkMsgRateLimit | SdkMsgBase + +export class SdkRunner implements AgentRunner { + private assignedSessionId: string | null = null + private sessionId: string | null = null + private abortController: AbortController | null = null + private workspace: string + private config: ServiceConfig + private queryFn: QueryFn + private agentEnv: Record | null = null + + constructor(config: ServiceConfig, workspace: string, queryFn: QueryFn = sdkQuery) { + this.config = config + this.workspace = workspace + this.queryFn = queryFn + } + + setAgentEnv(env: Record): void { + this.agentEnv = env + } + + async startSession(sessionId?: string): Promise { + // Reset state unconditionally to prevent stale fields on instance reuse or retry + this.assignedSessionId = null + this.sessionId = null + + if (sessionId !== undefined && !UUID_PATTERN.test(sessionId)) { + const preview = String(sessionId).slice(0, 64).replace(NEWLINE_PATTERN, ' ') + return new Error(`invalid_session_id: expected UUID format, got "${preview}"`) + } + + const validationErr = this.validateWorkspaceCwd() + if (validationErr) + return validationErr + + const id = sessionId ?? randomUUID() + this.assignedSessionId = id + // Set sessionId immediately so runTurn uses options.resume (cross-restart resume path). + // The SDK has NOT confirmed this session — it may reject if the session no longer exists. + this.sessionId = sessionId ?? null + return { sessionId: id, workspace: this.workspace } + } + + async runTurn( + session: AgentSession, + prompt: string, + _issue: Issue, + onMessage: (msg: AgentMessage) => void, + ): Promise { + const controller = new AbortController() + this.abortController = controller + + const timeoutHandle = setTimeout( + () => controller.abort(new Error('turn_timeout')), + this.config.claude.turn_timeout_ms, + ) + + const options: Options = { + cwd: session.workspace!, + permissionMode: this.config.claude.permission_mode as Options['permissionMode'], + abortController: controller, + } + + if (this.config.claude.permission_mode === 'bypassPermissions') { + options.allowDangerouslySkipPermissions = true + } + + if (this.config.claude.allowed_tools.length > 0) { + options.allowedTools = this.config.claude.allowed_tools + } + + if (this.sessionId) { + options.resume = this.sessionId + } + else if (this.assignedSessionId) { + options.sessionId = this.assignedSessionId as `${string}-${string}-${string}-${string}-${string}` + } + + if (this.config.claude.command !== 'claude') { + options.pathToClaudeCodeExecutable = this.config.claude.command + } + + if (this.config.claude.model) { + options.model = this.config.claude.model + } + + const sp = this.config.claude.system_prompt + if (sp.type === 'custom') { + options.systemPrompt = sp.value + } + else { + options.systemPrompt = sp + } + + options.effort = this.config.claude.effort + + const toolSpecs = getToolSpecs(this.config) + if (toolSpecs.length > 0) { + options.mcpServers = { + 'work-please-tools': createToolsMcpServer(this.config), + } + } + + if (this.config.claude.setting_sources.length > 0) { + options.settingSources = this.config.claude.setting_sources + } + + if (this.agentEnv) { + options.env = this.agentEnv + } + + const turnId = randomUUID() + let sessionIdConfirmed: string | null = null + let gotError = false + + try { + const q = this.queryFn({ prompt, options }) + + for await (const rawMsg of q) { + const msg = rawMsg as SdkMsg + if (msg.type === 'system' && (msg as SdkMsgInit).subtype === 'init') { + const initMsg = msg as SdkMsgInit + sessionIdConfirmed = initMsg.session_id + this.sessionId = sessionIdConfirmed + this.assignedSessionId = null // SDK confirmed — proposed ID no longer needed + onMessage({ + event: 'session_started', + timestamp: new Date(), + session_id: sessionIdConfirmed, + turn_id: turnId, + }) + } + else if (msg.type === 'result') { + const resultMsg = msg as SdkMsgSuccess | SdkMsgError + if (resultMsg.subtype === 'success') { + const successMsg = resultMsg as SdkMsgSuccess + onMessage({ + event: 'turn_completed', + timestamp: new Date(), + usage: { + input_tokens: successMsg.usage.input_tokens, + output_tokens: successMsg.usage.output_tokens, + total_tokens: successMsg.usage.input_tokens + successMsg.usage.output_tokens, + }, + }) + } + else { + const errMsg = resultMsg as SdkMsgError + gotError = true + onMessage({ + event: 'turn_failed', + timestamp: new Date(), + payload: { subtype: errMsg.subtype, errors: errMsg.errors }, + }) + } + } + else if (msg.type === 'rate_limit_event') { + const rlMsg = msg as SdkMsgRateLimit + onMessage({ + event: 'notification', + timestamp: new Date(), + rate_limits: rlMsg.rate_limit_info, + }) + } + else { + onMessage({ + event: 'notification', + timestamp: new Date(), + payload: rawMsg, + }) + } + } + + clearTimeout(timeoutHandle) + + if (gotError) { + return new Error('turn_failed') + } + + if (!sessionIdConfirmed) { + const err = new Error('no_session_started') + onMessage({ + event: 'startup_failed', + timestamp: new Date(), + payload: { reason: err.message }, + }) + return err + } + + return { turn_id: turnId, session_id: sessionIdConfirmed } + } + catch (err) { + clearTimeout(timeoutHandle) + const error = err instanceof Error ? err : new Error(String(err)) + // If init was never received, the session never started — report startup_failed + // and clear stale resume state so the next runTurn does not retry a poisoned session. + // If init was already received and the turn was aborted mid-execution, report turn_failed + // so callers can distinguish a startup failure from a mid-turn failure. + if (!sessionIdConfirmed) { + // Preserve resume state on transient pre-init failures so the next turn can retry. + // Only clear state for new sessions where no session was ever confirmed. + if (!options.resume) { + this.sessionId = null + this.assignedSessionId = null + } + } + onMessage({ + event: sessionIdConfirmed ? 'turn_failed' : 'startup_failed', + timestamp: new Date(), + payload: { reason: error.message }, + }) + return error + } + } + + stopSession(): void { + this.abortController?.abort() + this.assignedSessionId = null + this.sessionId = null + this.abortController = null + } + + private validateWorkspaceCwd(): Error | null { + const wsPath = resolve(this.workspace) + const root = resolve(this.config.workspace.root) + const rootWithSep = root + sep + + if (wsPath === root) + return new Error(`invalid_workspace_cwd: workspace_root ${wsPath}`) + if (!wsPath.startsWith(rootWithSep)) + return new Error(`invalid_workspace_cwd: outside_workspace_root ${wsPath}`) + return null + } +} diff --git a/apps/work-please/src/runner/types.ts b/apps/work-please/src/runner/types.ts new file mode 100644 index 00000000..2112accb --- /dev/null +++ b/apps/work-please/src/runner/types.ts @@ -0,0 +1,23 @@ +import type { AgentMessage, Issue } from '../types' + +export interface AgentSession { + sessionId: string + workspace: string | null +} + +export interface SessionResult { + turn_id: string + session_id: string +} + +export interface AgentRunner { + setAgentEnv: (env: Record) => void + startSession: (sessionId?: string) => Promise + runTurn: ( + session: AgentSession, + prompt: string, + issue: Issue, + onMessage: (msg: AgentMessage) => void, + ) => Promise + stopSession: () => void +} diff --git a/apps/work-please/src/server.test.ts b/apps/work-please/src/server.test.ts index bf15ed6f..c7cb4133 100644 --- a/apps/work-please/src/server.test.ts +++ b/apps/work-please/src/server.test.ts @@ -9,8 +9,9 @@ function makeConfig(overrides: Partial = {}): ServiceConfig { polling: { interval_ms: 30000 }, workspace: { root: '/tmp/test_ws' }, hooks: { after_create: null, before_run: null, after_run: null, before_remove: null, timeout_ms: 60000 }, - agent: { max_concurrent_agents: 5, max_turns: 20, max_retry_backoff_ms: 300000, max_concurrent_agents_by_state: {} }, + agent: { runner: 'sdk', max_concurrent_agents: 5, max_turns: 20, max_retry_backoff_ms: 300000, max_concurrent_agents_by_state: {} }, claude: { model: null, effort: 'high' as const, command: 'claude', permission_mode: 'bypassPermissions', allowed_tools: [], setting_sources: [], turn_timeout_ms: 3600000, read_timeout_ms: 5000, stall_timeout_ms: 300000, system_prompt: { type: 'preset', preset: 'claude_code' }, settings: { attribution: { commit: null, pr: null } } }, + code_action: { repository: null, workflow_file: '.github/workflows/claude.yml', ref: 'main', event_type: 'claude-code-action', poll_interval_ms: 10000, timeout_ms: 3600000, github_token: null }, env: {}, server: { port: null }, ...overrides, diff --git a/apps/work-please/src/tools.test.ts b/apps/work-please/src/tools.test.ts index addcc1f7..dae9c03d 100644 --- a/apps/work-please/src/tools.test.ts +++ b/apps/work-please/src/tools.test.ts @@ -8,8 +8,9 @@ function makeConfig(trackerKind: 'asana' | 'github_projects', apiKey: string | n polling: { interval_ms: 30000 }, workspace: { root: '/tmp' }, hooks: { after_create: null, before_run: null, after_run: null, before_remove: null, timeout_ms: 60000 }, - agent: { max_concurrent_agents: 5, max_turns: 20, max_retry_backoff_ms: 300000, max_concurrent_agents_by_state: {} }, + agent: { runner: 'sdk', max_concurrent_agents: 5, max_turns: 20, max_retry_backoff_ms: 300000, max_concurrent_agents_by_state: {} }, claude: { model: null, effort: 'high' as const, command: 'claude', permission_mode: 'bypassPermissions', allowed_tools: [], setting_sources: [], turn_timeout_ms: 3600000, read_timeout_ms: 5000, stall_timeout_ms: 300000, system_prompt: { type: 'preset', preset: 'claude_code' }, settings: { attribution: { commit: null, pr: null } } }, + code_action: { repository: null, workflow_file: '.github/workflows/claude.yml', ref: 'main', event_type: 'claude-code-action', poll_interval_ms: 10000, timeout_ms: 3600000, github_token: null }, env: {}, server: { port: null }, } diff --git a/apps/work-please/src/tracker/github-auth.test.ts b/apps/work-please/src/tracker/github-auth.test.ts index 89e0cbb0..14d0e8b9 100644 --- a/apps/work-please/src/tracker/github-auth.test.ts +++ b/apps/work-please/src/tracker/github-auth.test.ts @@ -30,8 +30,9 @@ function makeConfig(tracker: Partial): ServiceConfig { polling: { interval_ms: 30_000 }, workspace: { root: '/tmp' }, hooks: { after_create: null, before_run: null, after_run: null, before_remove: null, timeout_ms: 60_000 }, - agent: { max_concurrent_agents: 5, max_turns: 20, max_retry_backoff_ms: 300_000, max_concurrent_agents_by_state: {} }, + agent: { runner: 'sdk', max_concurrent_agents: 5, max_turns: 20, max_retry_backoff_ms: 300_000, max_concurrent_agents_by_state: {} }, claude: { model: null, effort: 'high' as const, command: 'claude', permission_mode: 'bypassPermissions', allowed_tools: [], setting_sources: [], turn_timeout_ms: 3_600_000, read_timeout_ms: 5_000, stall_timeout_ms: 300_000, system_prompt: { type: 'preset', preset: 'claude_code' }, settings: { attribution: { commit: null, pr: null } } }, + code_action: { repository: null, workflow_file: '.github/workflows/claude.yml', ref: 'main', event_type: 'claude-code-action', poll_interval_ms: 10000, timeout_ms: 3600000, github_token: null }, env: {}, server: { port: null }, } diff --git a/apps/work-please/src/types.ts b/apps/work-please/src/types.ts index c603fff7..12d3c705 100644 --- a/apps/work-please/src/types.ts +++ b/apps/work-please/src/types.ts @@ -97,6 +97,7 @@ export interface ServiceConfig { timeout_ms: number } agent: { + runner: 'sdk' | 'code_action' max_concurrent_agents: number max_turns: number max_retry_backoff_ms: number @@ -121,12 +122,23 @@ export interface ServiceConfig { } } } + code_action: CodeActionConfig env: Record server: { port: number | null } } +export interface CodeActionConfig { + repository: string | null + workflow_file: string + ref: string + event_type: string + poll_interval_ms: number + timeout_ms: number + github_token: string | null +} + export interface Workspace { path: string workspace_key: string