From 9cc4659fc7fb86667936b35ca957722722207c56 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 15 Mar 2026 18:37:03 +0900 Subject: [PATCH 1/8] refactor(agent): extract AgentRunner interface and runner factory - Create runner/types.ts with AgentRunner interface, AgentSession, SessionResult - Move AppServerClient to runner/sdk-runner.ts as SdkRunner implements AgentRunner - Add runner/index.ts with createRunner() factory - Convert agent-runner.ts to thin re-export shim for backward compatibility - Add agent.runner field ('sdk' | 'code_action') to ServiceConfig - Update test fixtures with runner: 'sdk' field --- apps/work-please/src/agent-runner.ts | 267 +----------------- apps/work-please/src/config.ts | 8 + apps/work-please/src/label.test.ts | 4 +- apps/work-please/src/runner/index.ts | 18 ++ apps/work-please/src/runner/sdk-runner.ts | 253 +++++++++++++++++ apps/work-please/src/runner/types.ts | 23 ++ apps/work-please/src/server.test.ts | 2 +- apps/work-please/src/tools.test.ts | 2 +- .../src/tracker/github-auth.test.ts | 2 +- apps/work-please/src/types.ts | 1 + 10 files changed, 313 insertions(+), 267 deletions(-) create mode 100644 apps/work-please/src/runner/index.ts create mode 100644 apps/work-please/src/runner/sdk-runner.ts create mode 100644 apps/work-please/src/runner/types.ts 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.ts b/apps/work-please/src/config.ts index 93ebcd97..743cedc8 100644 --- a/apps/work-please/src/config.ts +++ b/apps/work-please/src/config.ts @@ -61,6 +61,7 @@ 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), @@ -462,6 +463,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..ef957ac2 100644 --- a/apps/work-please/src/label.test.ts +++ b/apps/work-please/src/label.test.ts @@ -38,7 +38,7 @@ 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 } } }, env: {}, server: { port: null }, @@ -58,7 +58,7 @@ 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 } } }, env: {}, server: { port: null }, diff --git a/apps/work-please/src/runner/index.ts b/apps/work-please/src/runner/index.ts new file mode 100644 index 00000000..9a55e0fc --- /dev/null +++ b/apps/work-please/src/runner/index.ts @@ -0,0 +1,18 @@ +import type { ServiceConfig } from '../types' +import type { AgentRunner } from './types' +import { SdkRunner } from './sdk-runner' + +export function createRunner(config: ServiceConfig, workspace: string | null): AgentRunner { + const runner = config.agent.runner ?? 'sdk' + + if (runner === 'code_action') { + throw new Error('code_action runner is not yet implemented') + } + + 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..0d7310df 100644 --- a/apps/work-please/src/server.test.ts +++ b/apps/work-please/src/server.test.ts @@ -9,7 +9,7 @@ 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 } } }, env: {}, server: { port: null }, diff --git a/apps/work-please/src/tools.test.ts b/apps/work-please/src/tools.test.ts index addcc1f7..4b80d9a3 100644 --- a/apps/work-please/src/tools.test.ts +++ b/apps/work-please/src/tools.test.ts @@ -8,7 +8,7 @@ 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 } } }, 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..0bb1cc8b 100644 --- a/apps/work-please/src/tracker/github-auth.test.ts +++ b/apps/work-please/src/tracker/github-auth.test.ts @@ -30,7 +30,7 @@ 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 } } }, env: {}, server: { port: null }, diff --git a/apps/work-please/src/types.ts b/apps/work-please/src/types.ts index c603fff7..f1fffcce 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 From 0f84d7ae934a8c91941b8f5ee831517f5fd65dcc Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 15 Mar 2026 18:40:17 +0900 Subject: [PATCH 2/8] feat(config): add code_action runner config section - Add CodeActionConfig interface to types.ts (repository, workflow_file, ref, event_type, poll_interval_ms, timeout_ms, github_token) - Add buildCodeActionConfig() with defaults and env var resolution - Add validation: require github_token and repository for code_action runner - Skip claude command check when runner is code_action - Add tests for config parsing, defaults, env resolution, and validation --- apps/work-please/src/config.test.ts | 106 ++++++++++++++++++ apps/work-please/src/config.ts | 34 +++++- apps/work-please/src/label.test.ts | 2 + apps/work-please/src/server.test.ts | 1 + apps/work-please/src/tools.test.ts | 1 + .../src/tracker/github-auth.test.ts | 1 + apps/work-please/src/types.ts | 11 ++ 7 files changed, 153 insertions(+), 3 deletions(-) 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 743cedc8..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 { @@ -68,6 +73,7 @@ export function buildConfig(workflow: WorkflowDefinition): ServiceConfig { 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), @@ -99,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')) @@ -159,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 @@ -205,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 } diff --git a/apps/work-please/src/label.test.ts b/apps/work-please/src/label.test.ts index ef957ac2..8a742d4e 100644 --- a/apps/work-please/src/label.test.ts +++ b/apps/work-please/src/label.test.ts @@ -40,6 +40,7 @@ function makeGithubConfig(labelPrefix: string | null): ServiceConfig { hooks: { after_create: null, before_run: null, after_run: null, before_remove: null, timeout_ms: 60000 }, 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 }, } @@ -60,6 +61,7 @@ function makeAsanaConfig(): ServiceConfig { hooks: { after_create: null, before_run: null, after_run: null, before_remove: null, timeout_ms: 60000 }, 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/server.test.ts b/apps/work-please/src/server.test.ts index 0d7310df..c7cb4133 100644 --- a/apps/work-please/src/server.test.ts +++ b/apps/work-please/src/server.test.ts @@ -11,6 +11,7 @@ function makeConfig(overrides: Partial = {}): ServiceConfig { hooks: { after_create: null, before_run: null, after_run: null, before_remove: null, timeout_ms: 60000 }, 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 4b80d9a3..dae9c03d 100644 --- a/apps/work-please/src/tools.test.ts +++ b/apps/work-please/src/tools.test.ts @@ -10,6 +10,7 @@ function makeConfig(trackerKind: 'asana' | 'github_projects', apiKey: string | n hooks: { after_create: null, before_run: null, after_run: null, before_remove: null, timeout_ms: 60000 }, 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 0bb1cc8b..14d0e8b9 100644 --- a/apps/work-please/src/tracker/github-auth.test.ts +++ b/apps/work-please/src/tracker/github-auth.test.ts @@ -32,6 +32,7 @@ function makeConfig(tracker: Partial): ServiceConfig { hooks: { after_create: null, before_run: null, after_run: null, before_remove: null, timeout_ms: 60_000 }, 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 f1fffcce..12d3c705 100644 --- a/apps/work-please/src/types.ts +++ b/apps/work-please/src/types.ts @@ -122,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 From c38d2cfe46a9dc1d47da0074d4f3003ad4a9c69e Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 15 Mar 2026 18:42:11 +0900 Subject: [PATCH 3/8] feat(agent): add CodeActionRunner for GitHub Actions dispatch - Implement CodeActionRunner with repository_dispatch trigger - Dispatch via POST /repos/{owner}/{repo}/dispatches with client_payload containing prompt, issue context, and before/after run hooks - Poll for triggered run via GH Actions API, then poll until completion - Map workflow conclusion to AgentMessage events (success/failure) - Support run cancellation via stopSession() - Add comprehensive tests with mocked fetch --- .../src/runner/code-action-runner.test.ts | 230 ++++++++++++++ .../src/runner/code-action-runner.ts | 287 ++++++++++++++++++ apps/work-please/src/runner/index.ts | 3 +- 3 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 apps/work-please/src/runner/code-action-runner.test.ts create mode 100644 apps/work-please/src/runner/code-action-runner.ts 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..6de031e6 --- /dev/null +++ b/apps/work-please/src/runner/code-action-runner.test.ts @@ -0,0 +1,230 @@ +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, + has_unresolved_threads: false, + has_unresolved_human_threads: false, + 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, status: number, body?: unknown }>) { + const calls: Array<{ url: string, init?: RequestInit }> = [] + return { + calls, + fn: mock((url: string | URL, init?: RequestInit) => { + const urlStr = String(url) + calls.push({ url: urlStr, init }) + const match = responses.find(r => + typeof r.url === 'string' ? urlStr.includes(r.url) : r.url.test(urlStr), + ) + 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\/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\/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\/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\/runs\?/, status: 200, body: { workflow_runs: [{ id: 77, status: 'in_progress', conclusion: null }] } }, + // Poll keeps returning in_progress + { url: '/actions/runs/77', status: 200, body: { id: 77, status: 'in_progress', conclusion: null } }, + // Cancel + { url: '/actions/runs/77/cancel', 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..202c2062 --- /dev/null +++ b/apps/work-please/src/runner/code-action-runner.ts @@ -0,0 +1,287 @@ +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 ?? '', + ...(this.agentEnv ? { env: this.agentEnv } : {}), + }, + } + + 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 url = `https://api.github.com/repos/${repository}/actions/runs?event=repository_dispatch&per_page=5` + 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 index 9a55e0fc..1e5fc557 100644 --- a/apps/work-please/src/runner/index.ts +++ b/apps/work-please/src/runner/index.ts @@ -1,12 +1,13 @@ 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') { - throw new Error('code_action runner is not yet implemented') + return new CodeActionRunner(config) } if (!workspace) { From 371128a604bcae885c76de4c8670eaa4077efe69 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 15 Mar 2026 18:44:00 +0900 Subject: [PATCH 4/8] feat(orchestrator): support code_action runner via runner factory - Replace direct AppServerClient usage with createRunner() factory - Skip workspace creation and local hooks for code_action runner - Use AgentRunner interface for runAgentTurns() parameter type --- apps/work-please/src/orchestrator.ts | 48 ++++++++++++++++------------ 1 file changed, 28 insertions(+), 20 deletions(-) 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 { From 6d5333d3ee2635c27b95450dcae058ac6c1225f7 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 15 Mar 2026 18:44:59 +0900 Subject: [PATCH 5/8] docs: add code_action runner documentation - Update ARCHITECTURE.md with runner abstraction (SdkRunner + CodeActionRunner) - Document module structure with new runner/ directory - Add Code Action Runner lifecycle description - Update data flow diagram with runner factory pattern --- ARCHITECTURE.md | 56 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 26d8a6a3..842c7de4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -11,8 +11,8 @@ 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`. +launches a Claude Code agent session inside that workspace via one of two runners: +the `@anthropic-ai/claude-agent-sdk` (local) or `claude-code-action` (GitHub Actions). 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) From c69627178026b8ef2d7b4c0e69a1cc451da8b9eb Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 15 Mar 2026 19:21:12 +0900 Subject: [PATCH 6/8] chore(agent): apply AI code review suggestion Add created timestamp filter to findRun() API call to reduce race conditions when identifying the triggered workflow run. --- apps/work-please/src/runner/code-action-runner.test.ts | 2 -- apps/work-please/src/runner/code-action-runner.ts | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/work-please/src/runner/code-action-runner.test.ts b/apps/work-please/src/runner/code-action-runner.test.ts index 6de031e6..7fa64b30 100644 --- a/apps/work-please/src/runner/code-action-runner.test.ts +++ b/apps/work-please/src/runner/code-action-runner.test.ts @@ -42,8 +42,6 @@ function makeIssue(overrides: Partial = {}): Issue { blocked_by: [], pull_requests: [], review_decision: null, - has_unresolved_threads: false, - has_unresolved_human_threads: false, created_at: null, updated_at: null, project: null, diff --git a/apps/work-please/src/runner/code-action-runner.ts b/apps/work-please/src/runner/code-action-runner.ts index 202c2062..2d30b69e 100644 --- a/apps/work-please/src/runner/code-action-runner.ts +++ b/apps/work-please/src/runner/code-action-runner.ts @@ -197,7 +197,8 @@ export class CodeActionRunner implements AgentRunner { if (Date.now() > deadline) return new Error('timeout: run not found') - const url = `https://api.github.com/repos/${repository}/actions/runs?event=repository_dispatch&per_page=5` + const createdFilter = new Date(dispatchedAt - 5000).toISOString() + const url = `https://api.github.com/repos/${repository}/actions/runs?event=repository_dispatch&per_page=5&created=>=${createdFilter}` try { const resp = await fetch(url, { headers: { From 4e132301f18e5d27aa73faa1098419e4733ce37b Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 15 Mar 2026 19:51:17 +0900 Subject: [PATCH 7/8] chore(agent): apply AI code review suggestions - Remove agentEnv from client_payload to prevent secret exposure (P0) - Filter findRun by workflow_file to avoid tracking wrong run (P1) - Fix ARCHITECTURE.md wording for code_action runner (P2) - Improve mock URL matcher with method-specific matching (P2) --- ARCHITECTURE.md | 6 ++-- .../src/runner/code-action-runner.test.ts | 28 +++++++++++-------- .../src/runner/code-action-runner.ts | 4 +-- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 842c7de4..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 one of two runners: -the `@anthropic-ai/claude-agent-sdk` (local) or `claude-code-action` (GitHub Actions). +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 diff --git a/apps/work-please/src/runner/code-action-runner.test.ts b/apps/work-please/src/runner/code-action-runner.test.ts index 7fa64b30..52adf4e4 100644 --- a/apps/work-please/src/runner/code-action-runner.test.ts +++ b/apps/work-please/src/runner/code-action-runner.test.ts @@ -50,16 +50,20 @@ function makeIssue(overrides: Partial = {}): Issue { } // Helper to create a mock fetch that responds to specific API calls -function createMockFetch(responses: Array<{ url: string | RegExp, status: number, body?: unknown }>) { +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 => - typeof r.url === 'string' ? urlStr.includes(r.url) : r.url.test(urlStr), - ) + 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 })) } @@ -117,7 +121,7 @@ describe('CodeActionRunner', () => { // 1. Dispatch { url: '/dispatches', status: 204 }, // 2. Find run (first poll returns empty, second finds it) - { url: /\/actions\/runs\?/, status: 200, body: { workflow_runs: [{ id: 12345, status: 'in_progress', conclusion: null }] } }, + { 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' } }, ]) @@ -145,7 +149,7 @@ describe('CodeActionRunner', () => { it('emits turn_failed when workflow fails', async () => { const mockFetch = createMockFetch([ { url: '/dispatches', status: 204 }, - { url: /\/actions\/runs\?/, status: 200, body: { workflow_runs: [{ id: 99, status: 'completed', conclusion: 'failure' }] } }, + { 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 @@ -183,7 +187,7 @@ describe('CodeActionRunner', () => { const mockFetch = createMockFetch([ { url: '/dispatches', status: 204 }, - { url: /\/actions\/runs\?/, status: 200, body: { workflow_runs: [{ id: 1, status: 'completed', conclusion: 'success' }] } }, + { 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 @@ -203,11 +207,11 @@ describe('CodeActionRunner', () => { it('cancels in-progress run', async () => { const mockFetch = createMockFetch([ { url: '/dispatches', status: 204 }, - { url: /\/actions\/runs\?/, status: 200, body: { workflow_runs: [{ id: 77, status: 'in_progress', conclusion: null }] } }, - // Poll keeps returning in_progress - { url: '/actions/runs/77', status: 200, body: { id: 77, status: 'in_progress', conclusion: null } }, - // Cancel - { url: '/actions/runs/77/cancel', status: 202 }, + { 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 diff --git a/apps/work-please/src/runner/code-action-runner.ts b/apps/work-please/src/runner/code-action-runner.ts index 2d30b69e..6d7ec370 100644 --- a/apps/work-please/src/runner/code-action-runner.ts +++ b/apps/work-please/src/runner/code-action-runner.ts @@ -154,7 +154,6 @@ export class CodeActionRunner implements AgentRunner { issue_title: issue.title, before_run: this.config.hooks.before_run ?? '', after_run: this.config.hooks.after_run ?? '', - ...(this.agentEnv ? { env: this.agentEnv } : {}), }, } @@ -198,7 +197,8 @@ export class CodeActionRunner implements AgentRunner { return new Error('timeout: run not found') const createdFilter = new Date(dispatchedAt - 5000).toISOString() - const url = `https://api.github.com/repos/${repository}/actions/runs?event=repository_dispatch&per_page=5&created=>=${createdFilter}` + const workflowFile = encodeURIComponent(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: { From 9979abe1f4ded5cc66e9f61eb0b935491ff43425 Mon Sep 17 00:00:00 2001 From: Minsu Lee Date: Sun, 15 Mar 2026 20:00:14 +0900 Subject: [PATCH 8/8] chore(agent): extract workflow filename for API endpoint The GitHub Actions API expects the workflow filename (e.g., claude.yml), not the full path (.github/workflows/claude.yml). --- apps/work-please/src/runner/code-action-runner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/work-please/src/runner/code-action-runner.ts b/apps/work-please/src/runner/code-action-runner.ts index 6d7ec370..a8efb35d 100644 --- a/apps/work-please/src/runner/code-action-runner.ts +++ b/apps/work-please/src/runner/code-action-runner.ts @@ -197,7 +197,7 @@ export class CodeActionRunner implements AgentRunner { return new Error('timeout: run not found') const createdFilter = new Date(dispatchedAt - 5000).toISOString() - const workflowFile = encodeURIComponent(this.config.code_action.workflow_file) + 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, {