diff --git a/packages/api/routes/dockerRoutes.ts b/packages/api/routes/dockerRoutes.ts index 2a0bccf0e..9a8f4b020 100644 --- a/packages/api/routes/dockerRoutes.ts +++ b/packages/api/routes/dockerRoutes.ts @@ -385,7 +385,7 @@ export async function stopTaskExecution(taskIdOrJobId: string, options: StopTask * Returns the container ID when the container was stopped, null otherwise. */ async function stopRunningTaskContainer(taskId: string, state: TaskState, options: StopTaskExecutionOptions): Promise { - const entry = state.history.find(h => h.state === 'claude_execution' && h.metadata?.containerId); + const entry = state.history.findLast(h => h.state === 'claude_execution' && h.metadata?.containerId); const containerId = entry?.metadata?.containerId; if (!containerId) { console.log(`[stop-execution] No container ID found for task ${taskId}, relying on abort signal`); @@ -425,7 +425,7 @@ export function createDockerRoutes(deps: DockerRoutesDeps) { return; } const state = JSON.parse(stateData) as { history: Array<{ state: string; metadata?: { containerId?: string; containerName?: string } }> }; - const entry = state.history.find(h => h.state === 'claude_execution' && h.metadata?.containerId); + const entry = state.history.findLast(h => h.state === 'claude_execution' && h.metadata?.containerId); if (!entry?.metadata?.containerId) { res.status(404).json({ error: 'No Docker container info available for this task' }); return; diff --git a/packages/api/routes/goalRoutes.ts b/packages/api/routes/goalRoutes.ts new file mode 100644 index 000000000..3ae9d4ae9 --- /dev/null +++ b/packages/api/routes/goalRoutes.ts @@ -0,0 +1,851 @@ +/* eslint-disable max-lines -- goal creation and lifecycle controls share one owner-scoped HTTP boundary */ +import { createHash, randomUUID } from 'node:crypto'; +import type { Request, Response } from 'express'; +import type { Knex } from 'knex'; +import type { Queue } from 'bullmq'; +import { + AgentRegistry, + GOAL_CONTINUE_INPUT, + DEFAULT_GOAL_CHECKPOINT_INTERVAL_MINUTES, + GOAL_LAUNCH_STRATEGIES, + MAX_GOAL_CHECKPOINT_INTERVAL_MINUTES, + MIN_GOAL_CHECKPOINT_INTERVAL_MINUTES, + buildNativeGoalCommand, + codexGoalPromptValidationError, + getAuthenticatedOctokit, + goalJobId, + type GoalCapability, + type GoalJobData, + type GoalLaunchStrategy, + type Agent, +} from '@propr/core'; +import type { RedisClientType } from 'redis'; +import { stopTaskExecution, type StopTaskExecutionResult } from './dockerRoutes.js'; +import { serializeGoal, type GoalProjectionRow as GoalRow } from '../services/goalProjection.js'; + +interface GoalRoutesDeps { + db: Knex; + taskQueue: Queue; + redisClient: RedisClientType; + getCapabilities?: (options?: { force?: boolean }) => Promise; + stopExecution?: (taskId: string, options: Parameters[1]) => Promise; +} + +const repositoryPattern = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const cannedInputs = { + done: "What's done?", + left: "What's left?", +} as const; + +function currentOwnerId(req: Request): string | null { + return req.user?.id ? String(req.user.id) : null; +} + +function requestIdempotencyKey(req: Request): string | null { + const value = req.get('Idempotency-Key'); + return value && value.length <= 255 ? value : null; +} + +function requiredIdempotencyKey(req: Request, res: Response): string | null { + const key = requestIdempotencyKey(req); + if (!key) res.status(400).json({ error: 'A valid Idempotency-Key header is required' }); + return key; +} + +function mutationHash(operation: string, payload: Record): string { + return createHash('sha256').update(`${operation}\n${JSON.stringify(payload)}`).digest('hex'); +} + +class IdempotencyConflictError extends Error {} + +// eslint-disable-next-line max-params -- the mutation identity tuple is deliberately explicit at this persistence boundary +async function existingMutation( + db: Knex, + row: GoalRow, + key: string, + operation: string, + payloadHash: string, +): Promise<{ state?: string } | null> { + const createUse = await db('goals').where({ owner_id: row.owner_id, create_idempotency_key: key }).first('goal_id'); + if (createUse) { + throw new IdempotencyConflictError('Idempotency-Key was already used for a different goal, operation, or payload'); + } + const existing = await db('goal_inputs').where({ owner_id: row.owner_id, idempotency_key: key }).first(); + if (!existing) { + const checkpoint = await db('goal_checkpoints').where({ owner_id: row.owner_id, idempotency_key: key }).first(); + if (!checkpoint) return null; + if (checkpoint.goal_id !== row.goal_id || checkpoint.operation !== operation || checkpoint.payload_hash !== payloadHash) { + throw new IdempotencyConflictError('Idempotency-Key was already used for a different goal, operation, or payload'); + } + return checkpoint; + } + if (existing.goal_id !== row.goal_id || existing.operation !== operation || existing.payload_hash !== payloadHash) { + throw new IdempotencyConflictError('Idempotency-Key was already used for a different goal, operation, or payload'); + } + return existing; +} + +// eslint-disable-next-line max-params -- the mutation identity tuple is deliberately explicit at this persistence boundary +async function recordControlMutation( + db: Knex, + row: GoalRow, + key: string, + operation: string, + payloadHash: string, +): Promise { + try { + await db('goal_inputs').insert({ + input_id: randomUUID(), goal_id: row.goal_id, owner_id: row.owner_id, + idempotency_key: key, operation, payload_hash: payloadHash, + kind: 'control', message: '', state: 'delivered', created_at: db.fn.now(), delivered_at: db.fn.now(), + }); + } catch (error) { + if (!await existingMutation(db, row, key, operation, payloadHash)) throw error; + } +} + +async function findOwnedGoal(db: Knex, req: Request, res: Response): Promise { + const ownerId = currentOwnerId(req); + if (!ownerId) { + res.status(401).json({ error: 'Authentication required' }); + return null; + } + const goalId = Array.isArray(req.params.goalId) ? req.params.goalId[0] : req.params.goalId; + const row = await db('goals').where({ goal_id: goalId, owner_id: ownerId }).first() as GoalRow | undefined; + if (!row) res.status(404).json({ error: 'Goal not found' }); + return row ?? null; +} + +function validateCreateBody(body: Record): string | null { + if (typeof body.repository !== 'string' || !repositoryPattern.test(body.repository)) return 'repository must be in owner/repo format'; + if (typeof body.objective !== 'string' || body.objective.trim().length < 1 || body.objective.length > 65_536) return 'objective is required'; + if (!GOAL_LAUNCH_STRATEGIES.includes(body.launchStrategy as GoalLaunchStrategy)) return 'launchStrategy must be direct or orchestrate'; + if (typeof body.agentId !== 'string' || !body.agentId) return 'agentId is required'; + if (typeof body.model !== 'string' || !body.model) return 'model is required'; + if (body.baseBranch != null && (typeof body.baseBranch !== 'string' || body.baseBranch.length > 255)) return 'baseBranch is invalid'; + if (body.maxParallelTasks != null && (!Number.isSafeInteger(body.maxParallelTasks) || Number(body.maxParallelTasks) < 1 || Number(body.maxParallelTasks) > 32)) return 'maxParallelTasks must be an integer from 1 to 32'; + if (body.ultrafix != null && typeof body.ultrafix !== 'boolean') return 'ultrafix must be a boolean'; + return validateCreateCheckpointInterval(body); +} + +function validateCreateCheckpointInterval(body: Record): string | null { + if (body.checkpointIntervalMinutes == null) return null; + if (body.launchStrategy !== 'direct') return 'checkpointIntervalMinutes only applies to direct goals'; + if (!Number.isSafeInteger(body.checkpointIntervalMinutes) + || Number(body.checkpointIntervalMinutes) < MIN_GOAL_CHECKPOINT_INTERVAL_MINUTES + || Number(body.checkpointIntervalMinutes) > MAX_GOAL_CHECKPOINT_INTERVAL_MINUTES) { + return `checkpointIntervalMinutes must be an integer from ${MIN_GOAL_CHECKPOINT_INTERVAL_MINUTES} to ${MAX_GOAL_CHECKPOINT_INTERVAL_MINUTES}`; + } + return null; +} + +function buildCreateIdentity(body: Record): { operation: string; payloadHash: string } { + const operation = 'goal.create'; + const payloadHash = mutationHash(operation, { + repository: body.repository, objective: body.objective, launchStrategy: body.launchStrategy, + agentId: body.agentId, model: body.model, baseBranch: body.baseBranch ?? null, + maxParallelTasks: body.maxParallelTasks ?? null, ultrafix: body.ultrafix === true, + checkpointIntervalMinutes: body.launchStrategy === 'direct' + ? body.checkpointIntervalMinutes ?? DEFAULT_GOAL_CHECKPOINT_INTERVAL_MINUTES + : null, + }); + return { operation, payloadHash }; +} + +async function findExistingGoalCreation(options: { + db: Knex; + ownerId: string; + key: string; + operation: string; + payloadHash: string; +}): Promise { + const { db, ownerId, key, operation, payloadHash } = options; + const inputUse = await db('goal_inputs').where({ owner_id: ownerId, idempotency_key: key }).first('input_id'); + const checkpointUse = await db('goal_checkpoints').where({ owner_id: ownerId, idempotency_key: key }).first('checkpoint_id'); + if (inputUse || checkpointUse) { + throw new IdempotencyConflictError('Idempotency-Key was already used for a different operation or payload'); + } + const existing = await db('goals').where({ + owner_id: ownerId, + create_idempotency_key: key, + }).first(); + if (existing + && (existing.create_idempotency_operation !== operation || existing.create_payload_hash !== payloadHash)) { + throw new IdempotencyConflictError('Idempotency-Key was already used for a different operation or payload'); + } + return existing ?? null; +} + +type AgentSelection = { agent: Agent } | { error: string; status: number }; + +async function resolveCreationAgent( + body: Record, + getCapabilities: () => Promise, + initialPrompt: string, +): Promise { + const registry = AgentRegistry.getInstance(); + await registry.ensureInitialized(); + const agent = registry.getAgentById(body.agentId as string) || registry.getAgentByAlias(body.agentId as string); + if (!agent) return { error: 'Selected agent was not found', status: 400 }; + const promptError = agent.config.type === 'codex' ? codexGoalPromptValidationError(initialPrompt) : null; + if (promptError) return { error: promptError, status: 400 }; + if (!agent.config.supportedModels.includes(body.model as string) && agent.config.defaultModel !== body.model) { + return { error: 'Selected model is not supported by this agent', status: 400 }; + } + const capability = (await getCapabilities()).find(item => item.agentId === agent.config.id); + if (!capability?.goalCapable) { + return { error: capability?.reason || 'Selected agent does not support the required goal/session contract', status: 409 }; + } + return { agent }; +} + +export function createGoalRoutes(deps: GoalRoutesDeps) { + const getCapabilities = deps.getCapabilities ?? (async (options?: { force?: boolean }) => { + const registry = AgentRegistry.getInstance(); + await registry.ensureInitialized(); + return registry.getGoalCapabilities(options); + }); + const stop = deps.stopExecution ?? stopTaskExecution; + + /** Protect existing task/log surfaces when their authoritative task belongs to a goal. */ + const requireGoalTaskOwnership = async (req: Request, res: Response, next: () => void) => { + const taskId = Array.isArray(req.params.taskId) ? req.params.taskId[0] : req.params.taskId; + const sessionId = Array.isArray(req.params.sessionId) ? req.params.sessionId[0] : req.params.sessionId; + const correlationId = Array.isArray(req.params.correlationId) ? req.params.correlationId[0] : req.params.correlationId; + let goal: Pick | undefined; + if (taskId) { + goal = await deps.db('goals').select('goal_id', 'owner_id').where({ current_task_id: taskId }).first() as Pick | undefined; + } else if (sessionId) { + goal = await deps.db('goals') + .select('goal_id', 'owner_id') + .where({ session_id: sessionId }) + .orWhere({ conversation_id: sessionId }) + .first() as Pick | undefined; + } else if (correlationId) { + goal = await deps.db('goals').select('goal_id', 'owner_id').where({ goal_id: correlationId }).first() as Pick | undefined; + } + if (goal && goal.owner_id !== currentOwnerId(req)) { + res.status(404).json({ error: 'Task not found' }); + return; + } + if (goal && !['GET', 'HEAD'].includes(req.method)) { + res.status(409).json({ error: 'Use the goal controls to mutate a native goal task' }); + return; + } + next(); + }; + + const capabilities = async (req: Request, res: Response) => { + const registry = AgentRegistry.getInstance(); + await registry.ensureInitialized(); + const detected = await getCapabilities({ force: req.query?.recheck === 'true' }); + const agents = detected.map(capability => { + const agent = registry.getAgentById(capability.agentId); + return { + ...capability, + models: agent?.config.supportedModels ?? [], + defaultModel: agent?.config.defaultModel ?? null, + }; + }); + res.json({ agents }); + }; + + const list = async (req: Request, res: Response) => { + const ownerId = currentOwnerId(req); + if (!ownerId) return void res.status(401).json({ error: 'Authentication required' }); + const rows = await deps.db('goals').where({ owner_id: ownerId }).orderBy('updated_at', 'desc').limit(200); + res.json({ goals: await Promise.all(rows.map(row => serializeGoal(deps.db, deps.redisClient, row))) }); + }; + + const get = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (row) res.json({ goal: await serializeGoal(deps.db, deps.redisClient, row) }); + }; + + const create = async (req: Request, res: Response) => { + const body = (req.body ?? {}) as Record; + const validationError = validateCreateBody(body); + if (validationError) return void res.status(400).json({ error: validationError }); + const ownerId = currentOwnerId(req); + if (!ownerId) return void res.status(401).json({ error: 'Authentication required' }); + const createKey = requiredIdempotencyKey(req, res); + if (!createKey) return; + const { operation: createOperation, payloadHash: createPayloadHash } = buildCreateIdentity(body); + let existing: GoalRow | null; + try { + existing = await findExistingGoalCreation({ + db: deps.db, ownerId, key: createKey, operation: createOperation, payloadHash: createPayloadHash, + }); + } catch (error) { + if (error instanceof IdempotencyConflictError) return void res.status(409).json({ error: error.message }); + throw error; + } + if (existing) return void res.json({ goal: await serializeGoal(deps.db, deps.redisClient, existing) }); + + const launchStrategy = body.launchStrategy as GoalLaunchStrategy; + const initialPrompt = buildNativeGoalCommand({ + objective: body.objective as string, + launchStrategy, + maxParallelTasks: body.maxParallelTasks as number | null | undefined, + ultrafix: body.ultrafix === true, + }); + const selection = await resolveCreationAgent(body, getCapabilities, initialPrompt); + if ('error' in selection) return void res.status(selection.status).json({ error: selection.error }); + const { agent } = selection; + + const [repoOwner, repoName] = (body.repository as string).split('/'); + try { + const octokit = await getAuthenticatedOctokit(); + await octokit.request('GET /repos/{owner}/{repo}', { owner: repoOwner, repo: repoName }); + } catch { + return void res.status(403).json({ error: 'Repository is not accessible to this ProPR installation' }); + } + + const goalId = randomUUID(); + const claimId = randomUUID(); + const taskId = `goal-${goalId}`; + const now = new Date().toISOString(); + const row = { + goal_id: goalId, + owner_id: ownerId, + owner_login: req.user!.username, + repository: body.repository, + objective: body.objective as string, + launch_strategy: launchStrategy, + initial_prompt: initialPrompt, + base_branch: body.baseBranch || null, + agent_id: agent.config.id, + agent_alias: agent.config.alias, + agent_type: agent.config.type, + requested_model: body.model, + max_parallel_tasks: body.maxParallelTasks || null, + ultrafix: body.ultrafix === true, + checkpoint_interval_minutes: launchStrategy === 'direct' + ? body.checkpointIntervalMinutes ?? DEFAULT_GOAL_CHECKPOINT_INTERVAL_MINUTES + : null, + desired_state: 'running', + current_task_id: taskId, + run_generation: 0, + run_claim: claimId, + create_idempotency_key: createKey, + create_idempotency_operation: createOperation, + create_payload_hash: createPayloadHash, + artifact_refs: JSON.stringify([]), + artifact_stats: JSON.stringify({ issues: 0, openIssues: 0, pullRequests: 0, openPullRequests: 0 }), + created_at: now, + updated_at: now, + }; + try { + await deps.db('goals').insert(row); + } catch (error) { + const raced = await deps.db('goals').where({ + owner_id: ownerId, + create_idempotency_key: createKey, + }).first(); + if (raced) { + if (raced.create_idempotency_operation !== createOperation || raced.create_payload_hash !== createPayloadHash) { + return void res.status(409).json({ error: 'Idempotency-Key was already used for a different operation or payload' }); + } + return void res.json({ goal: await serializeGoal(deps.db, deps.redisClient, raced) }); + } + throw error; + } + const data: GoalJobData = { + goalId, taskId, repoOwner, repoName, generation: 0, claimId, + input: initialPrompt, + }; + try { + await deps.taskQueue.add('processGoal', data, { jobId: goalJobId(goalId, 0), attempts: 1 }); + } catch { + return void res.status(503).json({ + error: 'Goal was saved but its first attempt could not be queued; recovery will retry it safely', + goalId, + }); + } + const inserted = await deps.db('goals').where({ goal_id: goalId }).first() as GoalRow; + res.status(201).json({ goal: await serializeGoal(deps.db, deps.redisClient, inserted) }); + }; + + const pause = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (!row) return; + const key = requiredIdempotencyKey(req, res); + if (!key) return; + const operation = 'goal.pause'; + const payloadHash = mutationHash(operation, { goalId: row.goal_id }); + try { + if (await existingMutation(deps.db, row, key, operation, payloadHash) && row.pause_confirmed_at) { + return void res.json({ goal: await serializeGoal(deps.db, deps.redisClient, row) }); + } + } catch (error) { + if (error instanceof IdempotencyConflictError) return void res.status(409).json({ error: error.message }); + throw error; + } + if (row.result_state || row.desired_state === 'cancelled') return void res.status(409).json({ error: 'Goal is terminal' }); + if (row.desired_state !== 'paused') { + const changed = await deps.db('goals').where({ + goal_id: row.goal_id, + owner_id: row.owner_id, + run_generation: row.run_generation, + run_claim: row.run_claim, + desired_state: 'running', + }).whereNull('result_state').update({ + desired_state: 'paused', + paused_at: deps.db.fn.now(), + pause_confirmed_at: row.claimed_at ? null : deps.db.fn.now(), + updated_at: deps.db.fn.now(), + }); + if (changed !== 1) return void res.status(409).json({ error: 'Goal state changed before pause could be claimed' }); + } + // Codex pauses through native turn/interrupt. Other proven providers stop + // their resumable noninteractive invocation and resume the exact session. + if (row.agent_type !== 'codex' && row.claimed_at && row.session_id && !row.pause_confirmed_at) { + await stop(row.current_task_id, { + redisClient: deps.redisClient, + requestedBy: req.user!.username, + reason: 'Goal pause requested at the provider boundary.', + cancellationReason: 'goal_paused', + markCancelled: async () => undefined, + }); + } + await recordControlMutation(deps.db, row, key, operation, payloadHash); + const updated = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + res.json({ goal: await serializeGoal(deps.db, deps.redisClient, updated!) }); + }; + + async function addGoalInput( + row: GoalRow, + key: string, + message: string, + kind: 'input' | 'resume', + ): Promise<'inserted' | 'pending' | 'settled'> { + const operation = `goal.${kind}`; + const payloadHash = mutationHash(operation, { goalId: row.goal_id, message }); + const existing = await existingMutation(deps.db, row, key, operation, payloadHash); + if (existing) return existing.state === 'pending' ? 'pending' : 'settled'; + try { + await deps.db('goal_inputs').insert({ + input_id: randomUUID(), + goal_id: row.goal_id, + owner_id: row.owner_id, + idempotency_key: key, + operation, + payload_hash: payloadHash, + kind, + message, + state: 'pending', + created_at: deps.db.fn.now(), + }); + } catch (error) { + if (!await existingMutation(deps.db, row, key, operation, payloadHash)) throw error; + const raced = await existingMutation(deps.db, row, key, operation, payloadHash); + return raced?.state === 'pending' ? 'pending' : 'settled'; + } + return 'inserted'; + } + + async function beginPausedContinuation(row: GoalRow): Promise { + const generation = row.run_generation + 1; + const claimId = randomUUID(); + const completedPauseMs = row.paused_at ? Math.max(0, Date.now() - new Date(row.paused_at).getTime()) : 0; + const changed = await deps.db('goals') + .where({ + goal_id: row.goal_id, owner_id: row.owner_id, run_generation: row.run_generation, + run_claim: row.run_claim, desired_state: 'paused', + }) + .whereNull('result_state') + .whereNotNull('pause_confirmed_at') + .update({ + desired_state: 'running', paused_at: null, + paused_ms: Number(row.paused_ms || 0) + completedPauseMs, + run_generation: generation, run_claim: claimId, claimed_at: null, + attempt_heartbeat_at: null, active_turn_id: null, pause_confirmed_at: null, + resume_requested: false, updated_at: deps.db.fn.now(), + }); + if (changed !== 1) return false; + await deps.redisClient.del(`worker:abort:${row.current_task_id}`); + const [repoOwner, repoName] = row.repository.split('/'); + await deps.taskQueue.add('processGoal', { + goalId: row.goal_id, taskId: row.current_task_id, repoOwner, repoName, + generation, claimId, recovery: false, + } satisfies GoalJobData, { jobId: goalJobId(row.goal_id, generation), attempts: 1 }); + return true; + } + + const resume = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (!row) return; + const idempotencyKey = requiredIdempotencyKey(req, res); + if (!idempotencyKey) return; + const nativeCodexResume = row.agent_type === 'codex' && Boolean(row.session_id); + const resumeMessage = row.session_id || row.claimed_at ? GOAL_CONTINUE_INPUT : row.initial_prompt; + const resumeOperation = 'goal.resume'; + const resumePayloadHash = mutationHash(resumeOperation, { + goalId: row.goal_id, + ...(nativeCodexResume ? { transport: 'native-goal' } : { message: resumeMessage }), + }); + let resumeInserted = false; + try { + resumeInserted = !await existingMutation(deps.db, row, idempotencyKey, resumeOperation, resumePayloadHash); + } catch (error) { + if (error instanceof IdempotencyConflictError) return void res.status(409).json({ error: error.message }); + throw error; + } + if (!resumeInserted && row.desired_state === 'running') { + return void res.json({ goal: await serializeGoal(deps.db, deps.redisClient, row) }); + } + if (row.result_state || row.desired_state === 'cancelled') return void res.status(409).json({ error: 'Goal is terminal' }); + if (row.desired_state !== 'paused') return void res.status(409).json({ error: 'Goal is not paused' }); + if (resumeInserted) { + if (nativeCodexResume) { + await recordControlMutation(deps.db, row, idempotencyKey, resumeOperation, resumePayloadHash); + } else { + await addGoalInput(row, idempotencyKey, resumeMessage, 'resume'); + } + } + await deps.db('goals').where({ + goal_id: row.goal_id, owner_id: row.owner_id, run_generation: row.run_generation, + run_claim: row.run_claim, desired_state: 'paused', + }).whereNull('result_state').update({ + resume_requested: true, + ...(resumeInserted ? { control_generation: deps.db.raw('control_generation + 1') } : {}), + updated_at: deps.db.fn.now(), + }); + const latest = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + if (latest?.pause_confirmed_at) await beginPausedContinuation(latest); + const updated = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + res.json({ goal: await serializeGoal(deps.db, deps.redisClient, updated!) }); + }; + + const cancel = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (!row) return; + const key = requiredIdempotencyKey(req, res); + if (!key) return; + const operation = 'goal.cancel'; + const payloadHash = mutationHash(operation, { goalId: row.goal_id }); + try { + if (await existingMutation(deps.db, row, key, operation, payloadHash) && row.result_state === 'cancelled') { + return void res.json({ goal: await serializeGoal(deps.db, deps.redisClient, row) }); + } + } catch (error) { + if (error instanceof IdempotencyConflictError) return void res.status(409).json({ error: error.message }); + throw error; + } + if (row.result_state === 'cancelled') return void res.json({ goal: await serializeGoal(deps.db, deps.redisClient, row) }); + if (row.result_state) return void res.status(409).json({ error: 'Goal is already complete' }); + const finalPauseMs = row.paused_at ? Math.max(0, Date.now() - new Date(row.paused_at).getTime()) : 0; + if (row.desired_state !== 'cancelled') { + const changed = await deps.db('goals').where({ + goal_id: row.goal_id, owner_id: row.owner_id, + run_generation: row.run_generation, run_claim: row.run_claim, + }).whereNull('result_state').update({ + desired_state: 'cancelled', paused_at: null, + paused_ms: Number(row.paused_ms || 0) + finalPauseMs, + updated_at: deps.db.fn.now(), + }); + if (changed !== 1) return void res.status(409).json({ error: 'Goal state changed before cancellation could be claimed' }); + } + // A live Codex App Server performs the native /goal clear equivalent before + // interrupting its turn. Session-resume providers use the existing container + // stop path because they have no native goal control plane. + const stopped = row.agent_type === 'codex' && row.claimed_at + ? { + success: true, taskId: row.current_task_id, containerStopped: false, + removedQueuedJobs: 0, message: 'Native Codex goal clear requested', + } + : await stop(row.current_task_id, { + redisClient: deps.redisClient, + requestedBy: req.user!.username, + reason: 'Goal cancelled by user.', + cancellationReason: 'goal_cancelled', + ensureCancelled: true, + }); + // A signalled worker has not necessarily crossed its stop boundary yet. + // Leave the goal nonterminal so both an HTTP retry and leased recovery keep + // reconciling cleanup. Directly stopped/not-running work can finalize now. + if (stopped.containerStopped || stopped.notRunning || stopped.notFound || stopped.removedQueuedJobs > 0) { + await deps.db('goals').where({ + goal_id: row.goal_id, owner_id: row.owner_id, + run_generation: row.run_generation, run_claim: row.run_claim, + desired_state: 'cancelled', + }).whereNull('result_state').update({ + result_state: 'cancelled', completed_at: deps.db.fn.now(), + updated_at: deps.db.fn.now(), + }); + } + await recordControlMutation(deps.db, row, key, operation, payloadHash); + const updated = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + res.json({ goal: await serializeGoal(deps.db, deps.redisClient, updated!) }); + }; + + const remove = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (!row) return; + const needsStop = !row.result_state && ( + row.desired_state === 'running' + || row.desired_state === 'cancelled' + || (row.desired_state === 'paused' && !row.pause_confirmed_at) + ); + if (needsStop) { + if (row.desired_state !== 'cancelled') { + const fenced = await deps.db('goals').where({ + goal_id: row.goal_id, owner_id: row.owner_id, + run_generation: row.run_generation, run_claim: row.run_claim, + }).whereNull('result_state').update({ + desired_state: 'cancelled', paused_at: null, updated_at: deps.db.fn.now(), + }); + if (fenced !== 1) return void res.status(409).json({ error: 'Goal state changed before deletion could stop it' }); + } + const stopped = await stop(row.current_task_id, { + redisClient: deps.redisClient, + requestedBy: req.user!.username, + reason: 'Goal stopped before deletion.', + cancellationReason: 'goal_deleted', + ensureCancelled: true, + }); + if (stopped.abortSignalled && !stopped.containerStopped) { + return void res.status(409).json({ + error: 'Goal is still stopping', + message: 'The stop request was sent. Try deleting the goal again after the active execution has stopped.', + }); + } + } + await deps.db.transaction(async trx => { + await trx('goals').where({ goal_id: row.goal_id, owner_id: row.owner_id }).delete(); + await trx('llm_execution_details').whereIn('execution_id', function selectGoalExecutions() { + this.select('execution_id').from('llm_executions').where({ task_id: row.current_task_id }); + }).delete(); + await trx('llm_executions').where({ task_id: row.current_task_id }).delete(); + await trx('task_history').where({ task_id: row.current_task_id }).delete(); + await trx('tasks').where({ task_id: row.current_task_id }).delete(); + }); + res.status(204).send(); + }; + + // eslint-disable-next-line complexity -- model changes coordinate persisted state, provider stop semantics, and idempotency + const requestModel = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (!row) return; + const key = requiredIdempotencyKey(req, res); + if (!key) return; + const model = req.body?.model; + const operation = 'goal.model'; + const payloadHash = mutationHash(operation, { goalId: row.goal_id, model }); + try { + if (await existingMutation(deps.db, row, key, operation, payloadHash)) { + return void res.json({ goal: await serializeGoal(deps.db, deps.redisClient, row) }); + } + } catch (error) { + if (error instanceof IdempotencyConflictError) return void res.status(409).json({ error: error.message }); + throw error; + } + if (row.result_state) return void res.status(409).json({ error: 'Goal is terminal' }); + const registry = AgentRegistry.getInstance(); + await registry.ensureInitialized(); + const agent = registry.getAgentById(row.agent_id); + if (typeof model !== 'string' || !agent || (!agent.config.supportedModels.includes(model) && agent.config.defaultModel !== model)) return void res.status(400).json({ error: 'Unsupported model' }); + const changed = await deps.db('goals').where({ + goal_id: row.goal_id, owner_id: row.owner_id, + run_generation: row.run_generation, run_claim: row.run_claim, + }).whereNull('result_state').update({ + requested_model: model, + control_generation: deps.db.raw('control_generation + 1'), + ...(row.desired_state === 'running' ? { + desired_state: 'paused', paused_at: deps.db.fn.now(), + pause_confirmed_at: row.claimed_at ? null : deps.db.fn.now(), resume_requested: true, + } : {}), + updated_at: deps.db.fn.now(), + }); + if (changed !== 1) return void res.status(409).json({ error: 'Goal state changed before the model request was saved' }); + if (row.desired_state === 'running' && row.agent_type !== 'codex' && row.claimed_at && row.session_id) { + await stop(row.current_task_id, { + redisClient: deps.redisClient, requestedBy: req.user!.username, + reason: 'Goal model change requested at the next provider boundary.', + cancellationReason: 'goal_control_boundary', markCancelled: async () => undefined, + }); + } + const modelBoundary = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + if (modelBoundary?.desired_state === 'paused' && modelBoundary.pause_confirmed_at && modelBoundary.resume_requested) { + await beginPausedContinuation(modelBoundary); + } + await recordControlMutation(deps.db, row, key, operation, payloadHash); + const updated = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + res.json({ goal: await serializeGoal(deps.db, deps.redisClient, updated!) }); + }; + + // eslint-disable-next-line complexity -- input delivery branches by persisted lifecycle and provider resume capability + const input = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (!row) return; + const key = requiredIdempotencyKey(req, res); + if (!key) return; + if (row.result_state || row.desired_state === 'cancelled') return void res.status(409).json({ error: 'Goal is terminal' }); + const canned = req.body?.canned as keyof typeof cannedInputs | undefined; + const message = canned ? cannedInputs[canned] : req.body?.message; + if (typeof message !== 'string' || !message.trim() || message.length > 65_536) return void res.status(400).json({ error: 'A valid message or canned status request is required' }); + let inputDisposition: 'inserted' | 'pending' | 'settled'; + try { + inputDisposition = await addGoalInput(row, key, message.trim(), 'input'); + } catch (error) { + if (error instanceof IdempotencyConflictError) return void res.status(409).json({ error: error.message }); + throw error; + } + if (inputDisposition === 'settled' + || (inputDisposition === 'pending' && row.desired_state === 'running' && !row.claimed_at)) { + return void res.json({ goal: await serializeGoal(deps.db, deps.redisClient, row) }); + } + if (row.desired_state === 'paused') { + await deps.db('goals').where({ + goal_id: row.goal_id, owner_id: row.owner_id, + run_generation: row.run_generation, run_claim: row.run_claim, + desired_state: 'paused', + }).whereNull('result_state').update({ + resume_requested: true, + ...(inputDisposition === 'inserted' || !row.resume_requested + ? { control_generation: deps.db.raw('control_generation + 1') } + : {}), + updated_at: deps.db.fn.now(), + }); + if (row.agent_type !== 'codex' && row.claimed_at && !row.pause_confirmed_at) { + await stop(row.current_task_id, { + redisClient: deps.redisClient, requestedBy: req.user!.username, + reason: 'Goal input queued for the next provider boundary.', + cancellationReason: 'goal_control_boundary', markCancelled: async () => undefined, + }); + } + const latest = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + if (latest?.pause_confirmed_at) await beginPausedContinuation(latest); + } else if (row.agent_type === 'codex') { + if (inputDisposition === 'inserted') await deps.db('goals').where({ + goal_id: row.goal_id, owner_id: row.owner_id, + run_generation: row.run_generation, run_claim: row.run_claim, desired_state: 'running', + }).whereNull('result_state').update({ + control_generation: deps.db.raw('control_generation + 1'), updated_at: deps.db.fn.now(), + }); + } else if (!row.session_id) { + // Accept input even before the provider identity is emitted. The first + // invocation must still receive the immutable initial goal prompt; once + // its identity is durable the worker stops and resumes that exact session. + if (inputDisposition === 'inserted') await deps.db('goals').where({ + goal_id: row.goal_id, owner_id: row.owner_id, + run_generation: row.run_generation, run_claim: row.run_claim, desired_state: 'running', + }).whereNull('result_state').update({ + control_generation: deps.db.raw('control_generation + 1'), updated_at: deps.db.fn.now(), + }); + const identified = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + if (identified?.session_id && identified.desired_state === 'running') { + const paused = await deps.db('goals').where({ + goal_id: identified.goal_id, owner_id: identified.owner_id, + run_generation: identified.run_generation, run_claim: identified.run_claim, + desired_state: 'running', + }).whereNull('result_state').update({ + desired_state: 'paused', paused_at: deps.db.fn.now(), + pause_confirmed_at: identified.claimed_at ? null : deps.db.fn.now(), resume_requested: true, + updated_at: deps.db.fn.now(), + }); + if (paused === 1 && identified.claimed_at) await stop(identified.current_task_id, { + redisClient: deps.redisClient, requestedBy: req.user!.username, + reason: 'Goal input queued for exact whole-session resume.', + cancellationReason: 'goal_control_boundary', markCancelled: async () => undefined, + }); + const boundary = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + if (paused === 1 && boundary?.pause_confirmed_at) await beginPausedContinuation(boundary); + } + } else { + await deps.db('goals').where({ + goal_id: row.goal_id, owner_id: row.owner_id, + run_generation: row.run_generation, run_claim: row.run_claim, desired_state: 'running', + }).whereNull('result_state').update({ + desired_state: 'paused', paused_at: deps.db.fn.now(), + pause_confirmed_at: row.claimed_at ? null : deps.db.fn.now(), resume_requested: true, + control_generation: deps.db.raw('control_generation + 1'), updated_at: deps.db.fn.now(), + }); + if (row.claimed_at) { + await stop(row.current_task_id, { + redisClient: deps.redisClient, requestedBy: req.user!.username, + reason: 'Goal input queued for the next provider boundary.', + cancellationReason: 'goal_control_boundary', markCancelled: async () => undefined, + }); + } + const inputBoundary = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + if (inputBoundary?.pause_confirmed_at) await beginPausedContinuation(inputBoundary); + } + const updated = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + res.json({ goal: await serializeGoal(deps.db, deps.redisClient, updated!) }); + }; + + const checkpoint = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (!row) return; + const key = requiredIdempotencyKey(req, res); + if (!key) return; + const commitMessage = req.body?.commitMessage; + if (commitMessage != null && (typeof commitMessage !== 'string' || !commitMessage.trim() || commitMessage.length > 500)) { + return void res.status(400).json({ error: 'commitMessage must be a non-empty string of at most 500 characters' }); + } + if (row.launch_strategy !== 'direct') return void res.status(409).json({ error: 'Checkpoints only apply to direct goals' }); + if (row.result_state || row.desired_state === 'cancelled') return void res.status(409).json({ error: 'Goal is terminal' }); + if (row.desired_state !== 'running') return void res.status(409).json({ error: 'Resume the goal before requesting a checkpoint' }); + const operation = 'goal.checkpoint'; + const message = typeof commitMessage === 'string' ? commitMessage.trim() : null; + const payloadHash = mutationHash(operation, { goalId: row.goal_id, commitMessage: message }); + try { + if (!await existingMutation(deps.db, row, key, operation, payloadHash)) { + await deps.db('goal_checkpoints').insert({ + checkpoint_id: randomUUID(), goal_id: row.goal_id, owner_id: row.owner_id, + idempotency_key: key, operation, payload_hash: payloadHash, + kind: 'manual', commit_message: message, state: 'pending', + requested_generation: row.run_generation, requested_claim: row.run_claim, + created_at: deps.db.fn.now(), + }); + } + } catch (error) { + if (error instanceof IdempotencyConflictError) return void res.status(409).json({ error: error.message }); + const raced = await existingMutation(deps.db, row, key, operation, payloadHash); + if (!raced) throw error; + } + const updated = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + res.status(202).json({ goal: await serializeGoal(deps.db, deps.redisClient, updated!) }); + }; + + const requestCheckpointInterval = async (req: Request, res: Response) => { + const row = await findOwnedGoal(deps.db, req, res); + if (!row) return; + const key = requiredIdempotencyKey(req, res); + if (!key) return; + const minutes = req.body?.minutes; + if (!Number.isSafeInteger(minutes) + || Number(minutes) < MIN_GOAL_CHECKPOINT_INTERVAL_MINUTES + || Number(minutes) > MAX_GOAL_CHECKPOINT_INTERVAL_MINUTES) { + return void res.status(400).json({ + error: `minutes must be an integer from ${MIN_GOAL_CHECKPOINT_INTERVAL_MINUTES} to ${MAX_GOAL_CHECKPOINT_INTERVAL_MINUTES}`, + }); + } + if (row.launch_strategy !== 'direct') return void res.status(409).json({ error: 'Checkpoint frequency only applies to direct goals' }); + if (row.result_state || row.desired_state === 'cancelled') return void res.status(409).json({ error: 'Goal is terminal' }); + const operation = 'goal.checkpoint-frequency'; + const payloadHash = mutationHash(operation, { goalId: row.goal_id, minutes }); + try { + if (await existingMutation(deps.db, row, key, operation, payloadHash)) { + return void res.json({ goal: await serializeGoal(deps.db, deps.redisClient, row) }); + } + } catch (error) { + if (error instanceof IdempotencyConflictError) return void res.status(409).json({ error: error.message }); + throw error; + } + const changed = await deps.db('goals').where({ + goal_id: row.goal_id, owner_id: row.owner_id, + run_generation: row.run_generation, run_claim: row.run_claim, + }).whereNull('result_state').update({ checkpoint_interval_minutes: minutes, updated_at: deps.db.fn.now() }); + if (changed !== 1) return void res.status(409).json({ error: 'Goal state changed before checkpoint frequency was saved' }); + await recordControlMutation(deps.db, row, key, operation, payloadHash); + const updated = await deps.db('goals').where({ goal_id: row.goal_id }).first(); + res.json({ goal: await serializeGoal(deps.db, deps.redisClient, updated!) }); + }; + + return { + capabilities, list, get, create, pause, resume, cancel, remove, requestModel, input, + checkpoint, requestCheckpointInterval, requireGoalTaskOwnership, + }; +} diff --git a/packages/api/routes/index.ts b/packages/api/routes/index.ts index 3d0ddfbcf..ee1db4ab0 100644 --- a/packages/api/routes/index.ts +++ b/packages/api/routes/index.ts @@ -29,4 +29,5 @@ export { createUserRepoPreferencesRoutes } from './userRepoPreferencesRoutes.js' export { createAgentRuntimeRoutes } from './agentRuntimeRoutes.js'; export { createNotificationRoutes } from './notificationRoutes.js'; export { createAdminRoutes } from './adminRoutes.js'; +export { createGoalRoutes } from './goalRoutes.js'; export { createVisualPreviewAuthRoutes } from './visualPreviewAuthRoutes.js'; diff --git a/packages/api/routes/liveDetailsRoutes.ts b/packages/api/routes/liveDetailsRoutes.ts index fe593ab84..64a740744 100644 --- a/packages/api/routes/liveDetailsRoutes.ts +++ b/packages/api/routes/liveDetailsRoutes.ts @@ -263,7 +263,7 @@ async function loadStoredExecutionOutput(redisClient: RedisClientType, sessionId const output = await fs.readFile(outputPath, 'utf8'); return parseStoredOutputContent(output); } -async function parseActiveExecutionOutput(redisClient: RedisClientType, db: Knex, taskId: string): Promise { +async function parseActiveExecutionOutput(redisClient: RedisClientType, db: Knex, taskId: string): Promise<(ConversationResult & { nativeGoal?: ReturnType['nativeGoal'] }) | null> { const output = await redisClient.get(`agent:output:${taskId}`); if (!output?.trim()) return null; const executionStartTimestamp = await findExecutionStartTimestamp(redisClient, db, taskId); @@ -279,7 +279,8 @@ async function parseActiveExecutionOutput(redisClient: RedisClientType, db: Knex }) as unknown as Array>, todos: redisParsed.todos, currentTask: redisParsed.currentTask, - tokenUsage: redisParsed.tokenUsage + tokenUsage: redisParsed.tokenUsage, + nativeGoal: redisParsed.nativeGoal, }; } const parsedOutput = parseStoredOutputContent(output); @@ -288,6 +289,36 @@ async function parseActiveExecutionOutput(redisClient: RedisClientType, db: Knex ? withStableResultEventIds(taskId, 'redis', executionStartTimestamp ?? taskId, result) : null; } + +/** Provider-aware local projection shared by task details and goal summaries. */ +export async function projectTaskLiveDetails( + redisClient: RedisClientType, + db: Knex, + taskId: string, + sessionId?: string | null, +): Promise<(ConversationResult & { nativeGoal?: ReturnType['nativeGoal'] }) | null> { + const active = await parseActiveExecutionOutput(redisClient, db, taskId); + if (active) return active; + try { + const details = sessionId ? await parseExecutionDetailsFromDb(db, taskId, sessionId) : null; + if (details) return details; + const history = await db('task_history').where({ task_id: taskId }) + .orderBy('timestamp', 'desc').limit(20).select('metadata'); + const records = history.reverse().flatMap(entry => { + try { + const metadata = typeof entry.metadata === 'string' ? JSON.parse(entry.metadata) : entry.metadata; + return Array.isArray(metadata?.goalOutputRecords) + ? metadata.goalOutputRecords.filter((value: unknown): value is string => typeof value === 'string') + : []; + } catch { return []; } + }); + if (records.length === 0) return null; + const stored = parseStoredOutputContent(records.join('\n')); + return stored.parsed ?? stored.rawFallback; + } catch { + return null; + } +} export function parseStoredOutputContent(output: string): ParsedStoredOutput { if (!output.trim()) return { parsed: null, rawFallback: null, format: 'unknown' }; const format = detectStoredOutputFormat(output); diff --git a/packages/api/routes/llmLogsRoutes.ts b/packages/api/routes/llmLogsRoutes.ts index 13a1f0198..b99ca4df5 100644 --- a/packages/api/routes/llmLogsRoutes.ts +++ b/packages/api/routes/llmLogsRoutes.ts @@ -72,6 +72,28 @@ function applyLlmLogFilters(query: T, filters: LlmL return query; } +function applyGoalOwnershipFilter(query: T, ownerId?: string): T { + return query.whereNotExists(function() { + this.select('*').from('goals').whereRaw('goals.current_task_id = llm_logs.task_id'); + if (ownerId) this.whereNot('goals.owner_id', ownerId); + }) as T; +} + +function buildLlmLogQueries( + db: Knex, + selectColumns: string[], + hasGoalsTable: boolean, + ownerId?: string, +): { baseQuery: Knex.QueryBuilder; countQuery: Knex.QueryBuilder } { + const baseQuery = db('llm_logs').select(...selectColumns); + const countQuery = db('llm_logs').count('* as count'); + if (!hasGoalsTable) return { baseQuery, countQuery }; + return { + baseQuery: applyGoalOwnershipFilter(baseQuery, ownerId), + countQuery: applyGoalOwnershipFilter(countQuery, ownerId), + }; +} + interface UsageMetricRecordRow { id: number; llm_log_id: number; @@ -244,6 +266,7 @@ export function createLlmLogsRoutes(deps: LlmLogsRoutesDeps) { /** Process-wide cache for work-ref column existence. Only caches `true` * so a process started before the migration will re-check until it lands. */ let hasWorkRefColumnsCache = false; + let hasGoalsTableCache = false; async function checkWorkRefColumns(): Promise { if (hasWorkRefColumnsCache) return true; @@ -256,6 +279,17 @@ export function createLlmLogsRoutes(deps: LlmLogsRoutesDeps) { } } + async function checkGoalsTable(): Promise { + if (hasGoalsTableCache) return true; + try { + const result = await db.schema.hasTable('goals'); + if (result) hasGoalsTableCache = true; + return result; + } catch { + return false; + } + } + async function getLlmLogs(req: Request, res: Response): Promise { try { // Validate pagination parameters @@ -310,6 +344,7 @@ export function createLlmLogsRoutes(deps: LlmLogsRoutesDeps) { // Check if work-reference columns exist (cached after first successful check) const hasWorkRefColumns = await checkWorkRefColumns(); + const hasGoalsTable = await checkGoalsTable(); // Build and execute queries const baseColumns = [ @@ -327,9 +362,8 @@ export function createLlmLogsRoutes(deps: LlmLogsRoutesDeps) { ? [...baseColumns, ...workRefColumns] : baseColumns; - const baseQuery = db('llm_logs').select(...selectColumns); - - const countQuery = db('llm_logs').count('* as count'); + const ownerId = req.user?.id ? String(req.user.id) : undefined; + const { baseQuery, countQuery } = buildLlmLogQueries(db, selectColumns, hasGoalsTable, ownerId); // If work_type filter is requested but the column doesn't exist, return empty results if (!hasWorkRefColumns && workType) { diff --git a/packages/api/routes/queueRoutes.ts b/packages/api/routes/queueRoutes.ts index 2d5772302..cfaacb92b 100644 --- a/packages/api/routes/queueRoutes.ts +++ b/packages/api/routes/queueRoutes.ts @@ -33,9 +33,11 @@ export function createQueueRoutes(deps: QueueRoutesDeps) { ]); const liveJobs = serializeLiveJobs(activeJobs); const active = liveJobs.length; + const activeGoals = liveJobs.filter(job => job.name === 'processGoal').length; res.json({ waiting, active, + activeGoals, activeJobs: liveJobs, completed, failed, diff --git a/packages/api/routes/taskHelpers.ts b/packages/api/routes/taskHelpers.ts index a986ea47c..224f38c62 100644 --- a/packages/api/routes/taskHelpers.ts +++ b/packages/api/routes/taskHelpers.ts @@ -83,6 +83,9 @@ export async function getTasksFromDb( `; const baseQuery = db('tasks as t') + .where(function() { + this.whereNull('t.task_type').orWhereNot('t.task_type', 'goal'); + }) .join(latestHistorySubquery, function() { this.on('t.task_id', '=', 'h.task_id').andOn('h.rn', '=', db!.raw('?', [1])); }) diff --git a/packages/api/server.ts b/packages/api/server.ts index 5840f9df1..7f4002ebd 100644 --- a/packages/api/server.ts +++ b/packages/api/server.ts @@ -31,6 +31,7 @@ import { createUserRepoPreferencesRoutes, createAgentRuntimeRoutes, createNotificationRoutes, createAdminRoutes, + createGoalRoutes, createVisualPreviewAuthRoutes, createInstanceCatalogRoutes, attachmentUpload @@ -284,8 +285,14 @@ function setupRoutes(): void { const visualPreviewAuthRoutes = createVisualPreviewAuthRoutes(); const instanceCatalogRoutes = createInstanceCatalogRoutes(); const agentVersionRoutes = createAgentVersionRoutes(); + const goalRoutes = createGoalRoutes({ db, taskQueue, redisClient }); + + app.use(['/api/task/:taskId', '/api/task/:taskId/*path', '/api/tasks/:taskId', '/api/execution/:sessionId', '/api/execution/:sessionId/*path', '/api/llm-metrics/:correlationId'], goalRoutes.requireGoalTaskOwnership); const operationalRoutes: RouteEntry[] = [ + ['get', '/api/goals/capabilities', goalRoutes.capabilities], ['get', '/api/goals', goalRoutes.list], ['post', '/api/goals', goalRoutes.create], ['get', '/api/goals/:goalId', goalRoutes.get], ['delete', '/api/goals/:goalId', goalRoutes.remove], + ['post', '/api/goals/:goalId/pause', goalRoutes.pause], ['post', '/api/goals/:goalId/resume', goalRoutes.resume], ['post', '/api/goals/:goalId/cancel', goalRoutes.cancel], ['patch', '/api/goals/:goalId/model', goalRoutes.requestModel], ['post', '/api/goals/:goalId/input', goalRoutes.input], + ['post', '/api/goals/:goalId/checkpoint', goalRoutes.checkpoint], ['patch', '/api/goals/:goalId/checkpoint-frequency', goalRoutes.requestCheckpointInterval], ['get', '/api/status', statusRoutes.getStatus], ['get', '/api/tasks', taskRoutes.getTasks], ['get', '/api/tasks/revert-preview', taskRoutes.getRevertPreview], ['post', '/api/tasks/revert', taskRoutes.revertChanges], ['post', '/api/tasks/:taskId/followup', taskRoutes.postFollowup], ...createTaskDeleteRouteEntries({ taskRoutes }), ['get', '/api/task/:taskId/history', taskHistoryRoutes.getTaskHistory], ['get', '/api/task/:taskId/live-details', liveDetailsRoutes.getLiveDetails], ['get', '/api/task/:taskId/file-changes', fileChangesRoutes.getFileChanges], ['get', '/api/queue/stats', queueRoutes.getQueueStats], ['get', '/api/activity', queueRoutes.getActivity], ['get', '/api/metrics', queueRoutes.getMetrics], diff --git a/packages/api/services/goalProjection.ts b/packages/api/services/goalProjection.ts new file mode 100644 index 000000000..6295ccb99 --- /dev/null +++ b/packages/api/services/goalProjection.ts @@ -0,0 +1,176 @@ +import type { Knex } from 'knex'; +import type { RedisClientType } from 'redis'; +import { + parseGoalArtifacts, + type GoalArtifactStats, + type GoalLaunchStrategy, +} from '@propr/core'; +import { projectTaskLiveDetails } from '../routes/liveDetailsRoutes.js'; + +export interface GoalProjectionRow { + goal_id: string; + owner_id: string; + owner_login: string; + repository: string; + objective: string; + launch_strategy: GoalLaunchStrategy; + initial_prompt: string; + base_branch: string | null; + branch_name: string | null; + worktree_path: string | null; + agent_id: string; + agent_alias: string; + agent_type: string; + requested_model: string; + effective_model: string | null; + max_parallel_tasks: number | null; + ultrafix: number | boolean | null; + desired_state: 'running' | 'paused' | 'cancelled'; + result_state: 'completed' | 'failed' | 'cancelled' | null; + current_task_id: string; + session_id: string | null; + conversation_id: string | null; + run_generation: number; + run_claim: string | null; + claimed_at: string | null; + active_turn_id: string | null; + pause_confirmed_at: string | null; + resume_requested: number | boolean; + final_pr_number: number | null; + final_pr_url: string | null; + artifact_refs: string | unknown[] | null; + artifact_stats: string | GoalArtifactStats | null; + artifacts_checked_at: string | null; + failure_reason: string | null; + create_idempotency_key: string | null; + create_idempotency_operation: string | null; + create_payload_hash: string | null; + control_generation: number; + control_ack_generation: number; + task_reconciled_at: string | null; + created_at: string; + updated_at: string; + started_at: string | null; + paused_at: string | null; + paused_ms: number; + completed_at: string | null; + checkpoint_interval_minutes: number | null; + last_checkpoint_at: string | null; + last_checkpoint_commit_sha: string | null; + checkpoint_count: number; + checkpoint_error: string | null; +} + +function parseStats(value: GoalProjectionRow['artifact_stats']): GoalArtifactStats { + if (value && typeof value === 'object') return value; + if (typeof value === 'string') { + try { return JSON.parse(value) as GoalArtifactStats; } catch { /* use zero projection */ } + } + return { issues: 0, openIssues: 0, pullRequests: 0, openPullRequests: 0 }; +} + +function goalTiming(row: GoalProjectionRow): { elapsedMs: number; pausedMs: number; activeMs: number } { + const endMs = row.completed_at ? new Date(row.completed_at).getTime() : Date.now(); + const startMs = row.started_at ? new Date(row.started_at).getTime() : new Date(row.created_at).getTime(); + const currentPauseMs = row.desired_state === 'paused' && row.paused_at + ? Math.max(0, Date.now() - new Date(row.paused_at).getTime()) + : 0; + const pausedMs = Number(row.paused_ms || 0) + currentPauseMs; + const elapsedMs = Math.max(0, endMs - startMs); + return { elapsedMs, pausedMs, activeMs: Math.max(0, elapsedMs - pausedMs) }; +} + +function checkpointProjection( + row: GoalProjectionRow, + latestCheckpoint: Record | undefined, + pendingCheckpoint: Record | undefined, +) { + if (row.launch_strategy !== 'direct') return null; + return { + intervalMinutes: row.checkpoint_interval_minutes, + count: Number(row.checkpoint_count || 0), + lastAt: row.last_checkpoint_at, + lastCommitSha: row.last_checkpoint_commit_sha, + error: row.checkpoint_error, + pending: Boolean(pendingCheckpoint), + latest: latestCheckpoint ? { + kind: latestCheckpoint.kind, + state: latestCheckpoint.state, + commitSha: latestCheckpoint.commit_sha, + error: latestCheckpoint.error, + createdAt: latestCheckpoint.created_at, + completedAt: latestCheckpoint.completed_at, + } : null, + }; +} + +function liveSummary(live: Awaited>) { + return { + currentTask: live?.currentTask ?? null, + todos: live?.todos ?? [], + tokenUsage: live?.tokenUsage ?? null, + nativeGoal: live?.nativeGoal ?? null, + }; +} + +export async function serializeGoal( + db: Knex, + redis: RedisClientType, + source: GoalProjectionRow, +) { + const row = source; + const live = await projectTaskLiveDetails(redis, db, row.current_task_id, row.session_id); + const latestHistory = await db('task_history') + .where({ task_id: row.current_task_id }) + .orderBy('timestamp', 'desc') + .first(); + const latestCheckpoint = row.launch_strategy === 'direct' + ? await db('goal_checkpoints').where({ goal_id: row.goal_id, owner_id: row.owner_id }) + .orderBy('created_at', 'desc').first() + : null; + const pendingCheckpoint = row.launch_strategy === 'direct' + ? await db('goal_checkpoints').where({ goal_id: row.goal_id, owner_id: row.owner_id }) + .whereIn('state', ['pending', 'processing']).first('checkpoint_id') + : null; + const timing = goalTiming(row); + return { + id: row.goal_id, + owner: row.owner_login, + repository: row.repository, + objective: row.objective, + launchStrategy: row.launch_strategy, + initialPrompt: row.initial_prompt, + baseBranch: row.base_branch, + branchName: row.branch_name, + worktreePath: row.worktree_path, + agent: { id: row.agent_id, alias: row.agent_alias, type: row.agent_type }, + requestedModel: row.requested_model, + effectiveModel: row.effective_model, + maxParallelTasks: row.max_parallel_tasks, + ultrafix: row.ultrafix == null ? null : Boolean(row.ultrafix), + desiredState: row.desired_state, + resultState: row.result_state, + failureReason: row.failure_reason, + pausePending: row.desired_state === 'paused' && !row.pause_confirmed_at, + control: { + requestGeneration: Number(row.control_generation || 0), + acknowledgedGeneration: Number(row.control_ack_generation || 0), + pending: Number(row.control_ack_generation || 0) < Number(row.control_generation || 0), + }, + taskId: row.current_task_id, + sessionId: row.session_id, + conversationId: row.conversation_id, + finalPr: row.final_pr_url ? { number: row.final_pr_number, url: row.final_pr_url } : null, + checkpoint: checkpointProjection(row, latestCheckpoint, pendingCheckpoint), + artifacts: parseGoalArtifacts(row.artifact_refs as string | null), + artifactStats: parseStats(row.artifact_stats), + liveSummary: liveSummary(live), + taskState: latestHistory?.state ?? 'pending', + createdAt: row.created_at, + updatedAt: row.updated_at, + startedAt: row.started_at, + pausedAt: row.paused_at, + completedAt: row.completed_at, + ...timing, + }; +} diff --git a/packages/api/services/queueBroadcaster.ts b/packages/api/services/queueBroadcaster.ts index faeab2e48..c0373f720 100644 --- a/packages/api/services/queueBroadcaster.ts +++ b/packages/api/services/queueBroadcaster.ts @@ -85,17 +85,20 @@ export class QueueBroadcaster { */ async broadcastQueueStats(): Promise { try { - const [waiting, active, completed, failed, delayed] = await Promise.all([ + const [waiting, activeJobs, completed, failed, delayed] = await Promise.all([ this.queue.getWaitingCount(), - this.queue.getActiveCount(), + this.queue.getJobs(['active']), this.queue.getCompletedCount(), this.queue.getFailedCount(), this.queue.getDelayedCount() ]); + const active = activeJobs.length; + const activeGoals = activeJobs.filter(job => job.name === 'processGoal').length; const stats: QueueStatsData = { waiting, active, + activeGoals, completed, failed, delayed, diff --git a/packages/api/services/redisOutputParser.ts b/packages/api/services/redisOutputParser.ts index ba4778fe1..041f87914 100644 --- a/packages/api/services/redisOutputParser.ts +++ b/packages/api/services/redisOutputParser.ts @@ -10,6 +10,15 @@ import { parseVibeTranscriptOutput, processVibeEvent } from './redisOutputParser /** Result from parsing Redis output */ export interface ParsedRedisOutput { events: ConversationEvent[]; todos: TodoItem[]; currentTask: string | null; tokenUsage: TokenUsageInfo | null; totalEventCount: number; + nativeGoal: NativeGoalProjection | null; +} + +export interface NativeGoalProjection { + objective: string; + status: string; + tokenBudget: number | null; + tokensUsed: number; + timeUsedSeconds: number; } export interface RedisOutputParseOptions { @@ -32,6 +41,7 @@ interface ParseState { emittedAntigravityToolUseIds: Set; emittedOpenCodeToolUseIds: Set; emittedOpenCodeToolResultIds: Set; + nativeGoal: NativeGoalProjection | null; } interface OpenCodeRedisEventUsage { @@ -72,6 +82,20 @@ interface CodexItem { items?: Array<{ text: string; completed: boolean }>; } +interface CodexAppServerEvent { + id?: number; + method?: string; + error?: { message?: string }; + params?: { + item?: Record; + plan?: Array<{ step?: string; status?: string }>; + tokenUsage?: Record; + usage?: Record; + message?: string; + goal?: Record; + }; +} + /** Max content length for truncation */ const MAX_CONTENT_LENGTH = 2000; const OPEN_CODE_TOOL_USE_TYPES = ['tool_use', 'tool', 'tool_call']; @@ -225,6 +249,89 @@ function processCodexEvent(event: CodexEvent, timestamp: string, state: ParseSta } } +function appServerUsage(event: CodexAppServerEvent): ParseState['tokenUsage'] | null { + const params = event.params ?? {}; + const outer = (params.tokenUsage ?? params.usage ?? {}) as Record; + const usage = (outer.total ?? outer) as Record; + const normalized = { + input_tokens: Number(usage.inputTokens ?? usage.input_tokens ?? 0), + output_tokens: Number(usage.outputTokens ?? usage.output_tokens ?? 0), + cache_creation_input_tokens: Number(usage.cacheCreationInputTokens ?? usage.cache_creation_input_tokens ?? 0), + cache_read_input_tokens: Number(usage.cachedInputTokens ?? usage.cache_read_input_tokens ?? 0), + }; + return hasRedisTokenUsage(normalized) ? normalized : null; +} + +function processAppServerItem(item: Record, timestamp: string, state: ParseState): void { + const type = item.type; + if (type === 'agentMessage' && typeof item.text === 'string') { + state.events.push({ type: 'thought', content: truncateContent(item.text), timestamp }); + return; + } + if (type === 'reasoning') { + const summary = Array.isArray(item.summary) ? item.summary.join('\n') : textFromValue(item.summary); + if (summary) state.events.push({ type: 'thought', content: truncateContent(summary), timestamp }); + return; + } + if (type === 'commandExecution') { + state.events.push({ type: 'tool_use', toolName: 'Bash', input: { command: item.command }, timestamp }); + if (typeof item.aggregatedOutput === 'string') { + state.events.push({ type: 'tool_result', result: truncateContent(item.aggregatedOutput), isError: Number(item.exitCode ?? 0) !== 0, timestamp }); + } + return; + } + if (type === 'fileChange' && Array.isArray(item.changes)) { + state.events.push({ type: 'tool_use', toolName: 'FileChange', input: { changes: item.changes }, timestamp }); + return; + } + if (type === 'mcpToolCall' || type === 'dynamicToolCall' || type === 'collabToolCall') { + const toolName = String(item.tool ?? item.server ?? type); + state.events.push({ type: 'tool_use', toolName, input: (item.arguments ?? {}) as Record, timestamp }); + if (item.result || item.error) state.events.push({ type: 'tool_result', result: item.result ?? item.error, isError: Boolean(item.error), timestamp }); + } +} + +function processAppServerPlan(event: CodexAppServerEvent, state: ParseState): void { + state.todos = (event.params?.plan ?? []).map((entry, index) => ({ + id: `plan-${index}`, + content: entry.step || `Step ${index + 1}`, + status: entry.status === 'completed' ? 'completed' : entry.status === 'inProgress' ? 'in_progress' : 'pending', + })); +} + +function processAppServerGoal(event: CodexAppServerEvent, state: ParseState): void { + const goal = event.params?.goal; + if (typeof goal?.objective !== 'string' || typeof goal.status !== 'string') return; + state.nativeGoal = { + objective: goal.objective, + status: goal.status, + tokenBudget: typeof goal.tokenBudget === 'number' ? goal.tokenBudget : null, + tokensUsed: Number(goal.tokensUsed ?? 0), + timeUsedSeconds: Number(goal.timeUsedSeconds ?? 0), + }; +} + +function processAppServerDiagnostic(event: CodexAppServerEvent, timestamp: string, state: ParseState): void { + const content = event.error?.message || event.params?.message; + if (content) { + state.events.push({ type: 'tool_result', result: content, isError: event.method === 'error', timestamp }); + } +} + +function processCodexAppServerEvent(event: CodexAppServerEvent, timestamp: string, state: ParseState): boolean { + if (!event.method) return typeof event.id === 'number'; + if (event.method === 'turn/plan/updated') processAppServerPlan(event, state); + else if (event.method === 'item/completed' && event.params?.item) processAppServerItem(event.params.item, timestamp, state); + else if (event.method === 'thread/tokenUsage/updated') { + const usage = appServerUsage(event); + if (usage) mergeRedisTokenUsageByMax(state.tokenUsage, usage); + } else if (event.method === 'thread/goal/updated') processAppServerGoal(event, state); + else if (event.method === 'error' || event.method === 'warning') processAppServerDiagnostic(event, timestamp, state); + return event.method === 'error' || event.method === 'warning' + || event.method.startsWith('thread/') || event.method.startsWith('turn/') + || event.method.startsWith('item/') || event.method.startsWith('model/'); +} + /** * Process Antigravity events (message, tool_use, tool_result, result) */ @@ -651,6 +758,8 @@ function parseLine(line: string, state: ParseState): void { ? normalizeOpenCodeTimestamp(rawTimestamp) : rawTimestamp || getNextSyntheticTimestamp(state); + if (processCodexAppServerEvent(event, timestamp, state)) return; + // Session-qualified OpenCode events overlap with Antigravity's tool // envelopes, so preserve their stronger identity before generic routing. if (shouldProcessOpenCodeBeforeCodex(event) && processOpenCodeEvent(event, timestamp, state)) return; @@ -708,7 +817,8 @@ export function parseRedisOutput(lines: string[], options: RedisOutputParseOptio seenEventFingerprints: new Set(), emittedAntigravityToolUseIds: new Set(), emittedOpenCodeToolUseIds: new Set(), - emittedOpenCodeToolResultIds: new Set() + emittedOpenCodeToolResultIds: new Set(), + nativeGoal: null, }; if (parseVibeTranscriptOutput(lines.join('\n'), state)) { @@ -718,7 +828,8 @@ export function parseRedisOutput(lines: string[], options: RedisOutputParseOptio todos: state.todos, currentTask: null, tokenUsage: hasTokens ? state.tokenUsage : null, - totalEventCount: state.events.length + totalEventCount: state.events.length, + nativeGoal: state.nativeGoal, }; } @@ -733,5 +844,12 @@ export function parseRedisOutput(lines: string[], options: RedisOutputParseOptio const inProgressTask = state.todos.find(t => t.status === 'in_progress'); const hasTokens = hasRedisTokenUsage(state.tokenUsage); - return { events: state.events, todos: state.todos, currentTask: inProgressTask ? inProgressTask.content : null, tokenUsage: hasTokens ? state.tokenUsage : null, totalEventCount: state.events.length }; + return { + events: state.events, + todos: state.todos, + currentTask: inProgressTask ? inProgressTask.content : null, + tokenUsage: hasTokens ? state.tokenUsage : null, + totalEventCount: state.events.length, + nativeGoal: state.nativeGoal, + }; } diff --git a/packages/api/services/socketSubscriptions.ts b/packages/api/services/socketSubscriptions.ts index 0afa5ab60..9723fabb2 100644 --- a/packages/api/services/socketSubscriptions.ts +++ b/packages/api/services/socketSubscriptions.ts @@ -132,11 +132,19 @@ export class SocketSubscriptionManager { if (socketSubscriptions.size === 0) this.pendingSubscriptions.delete(socket); } - async taskExists(taskId: string): Promise { + async taskExists(taskId: string, socket?: Socket): Promise { const queueDependencies = this.dependencies.getQueueDependencies(); if (!queueDependencies) return false; try { const stateKey = `${queueDependencies.workerStateOptions?.keyPrefix ?? 'worker:state:'}${taskId}`; + if (taskId.startsWith('goal-')) { + if (!socket) return false; + const goal = await queueDependencies.db('goals') + .select('owner_id') + .where({ current_task_id: taskId }) + .first() as { owner_id?: string } | undefined; + if (!goal || goal.owner_id !== this.getPrincipal(socket).user.id) return false; + } if (await queueDependencies.redisClient.get(stateKey)) return true; const task = await queueDependencies.db('tasks') .select('task_id') @@ -221,7 +229,7 @@ export class SocketSubscriptionManager { if (!await this.join(socket, { event: 'subscribe:task', room: taskRoom(taskId), - authorize: () => this.taskExists(taskId), + authorize: () => this.taskExists(taskId, socket), })) return; console.log(`[SocketService] Client ${socket.id} subscribed to task:${taskId}`); }); @@ -241,7 +249,7 @@ export class SocketSubscriptionManager { await this.join(socket, { event: 'subscribe:task:live', room: `task:live:${taskId}`, - authorize: () => this.taskExists(taskId), + authorize: () => this.taskExists(taskId, socket), onJoined: async () => { await this.dependencies.taskWatcherManager.startTaskWatcher(taskId); await this.dependencies.taskWatcherManager.sendTaskLiveUpdate(taskId, true); diff --git a/packages/api/services/taskWatcher.ts b/packages/api/services/taskWatcher.ts index 7841cc891..0aa981d05 100644 --- a/packages/api/services/taskWatcher.ts +++ b/packages/api/services/taskWatcher.ts @@ -88,7 +88,7 @@ export class TaskWatcherManager { } // Get agent config to find the correct log path - const agentConfig = await findAgentConfigForTask(taskId); + const agentConfig = await findAgentConfigForTask(taskId, this.deps?.db); const agentType = agentConfig?.type || 'claude'; const agentRoot = agentConfig ? resolveConfigPath(agentConfig.configPath) : path.join(os.homedir(), '.claude'); diff --git a/packages/api/services/taskWatcherLookup.ts b/packages/api/services/taskWatcherLookup.ts index 0954203f4..a9b0dcb1a 100644 --- a/packages/api/services/taskWatcherLookup.ts +++ b/packages/api/services/taskWatcherLookup.ts @@ -10,9 +10,27 @@ export interface TaskWatcherLookupDeps { /** * Find the agent config for a task based on agent alias in task ID */ -export async function findAgentConfigForTask(taskId: string): Promise { +export async function findAgentConfigForTask(taskId: string, database?: Knex): Promise { try { const agents = await loadAgents(); + + // Goal task IDs are deliberately stable and provider-agnostic so that a + // continuation can reuse the same task identity. Resolve their immutable + // provider selection from the goal envelope instead of guessing from the + // task ID. Output, todos and usage continue to come from the normal task + // watcher/parsers. + if (database && taskId.startsWith('goal-')) { + const goal = await database('goals') + .where({ current_task_id: taskId }) + .first('agent_id', 'agent_alias', 'agent_type'); + if (goal) { + const selected = agents.find(agent => agent.id === goal.agent_id) + ?? agents.find(agent => agent.alias === goal.agent_alias) + ?? agents.find(agent => agent.type === goal.agent_type); + if (selected) return selected; + } + } + for (const agent of agents) { if (taskId.includes(`-${agent.alias}-`)) { return agent; diff --git a/packages/api/test/goalRoutes.test.ts b/packages/api/test/goalRoutes.test.ts new file mode 100644 index 000000000..d98816d2a --- /dev/null +++ b/packages/api/test/goalRoutes.test.ts @@ -0,0 +1,378 @@ +import assert from 'node:assert/strict'; +import { mock, test } from 'node:test'; +import type { Request, Response } from 'express'; +import knex from 'knex'; +import { AgentRegistry, closeConnection } from '@propr/core'; +import { up as createGoals } from '../../core/src/db/migrations/20260902000000_create_goals.js'; +import { up as hardenGoals } from '../../core/src/db/migrations/20260902010000_harden_native_goals.js'; +import { up as addGoalCheckpoints } from '../../core/src/db/migrations/20260903000000_add_direct_goal_checkpoints.js'; +import { createGoalRoutes } from '../routes/goalRoutes.js'; + +function request(userId: string, params: Record = {}, body: unknown = {}): Request { + return { + user: { id: userId, username: userId }, params, body, method: 'GET', + get: () => undefined, + } as unknown as Request; +} + +function response() { + const state: { status: number; body?: unknown } = { status: 200 }; + const res = { + status(code: number) { state.status = code; return this; }, + json(body: unknown) { state.body = body; return this; }, + send(body?: unknown) { state.body = body; return this; }, + } as unknown as Response; + return { res, state }; +} + +test('goal routes keep metadata owner-scoped and queue ordinary input on the same task/session', async () => { + const database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const queued: Array<{ name: string; data: Record; options: { jobId: string } }> = []; + const stopped: string[] = []; + const stopAttempts = new Map(); + const capabilityRequests: Array<{ force?: boolean } | undefined> = []; + try { + await createGoals(database); + await hardenGoals(database); + await addGoalCheckpoints(database); + await database.schema.createTable('task_history', table => { + table.increments('id'); + table.string('task_id'); + table.string('state'); + table.timestamp('timestamp'); + }); + await database.schema.createTable('tasks', table => { + table.string('task_id').primary(); + }); + await database.schema.createTable('llm_executions', table => { + table.string('execution_id').primary(); + table.string('task_id'); + }); + await database.schema.createTable('llm_execution_details', table => { + table.string('execution_id'); + }); + const common = { + owner_login: 'alice', repository: 'acme/repo', objective: 'Ship it', + launch_strategy: 'direct', initial_prompt: '/goal Ship it\n\nSaved policy', + agent_id: 'agent-1', agent_alias: 'claude', agent_type: 'claude', requested_model: 'gpt-5.6', + desired_state: 'paused', run_generation: 2, run_claim: 'claim-2', session_id: 'thread-1', + branch_name: 'goal/ship-it', worktree_path: '/worktrees/goal-1', + pause_confirmed_at: new Date().toISOString(), + artifact_stats: JSON.stringify({ issues: 0, openIssues: 0, pullRequests: 0, openPullRequests: 0 }), + artifacts_checked_at: '2000-01-01T00:00:00.000Z', + }; + await database('goals').insert([ + { ...common, goal_id: 'goal-1', owner_id: 'owner-1', current_task_id: 'goal-task-1' }, + { ...common, goal_id: 'goal-2', owner_id: 'owner-2', current_task_id: 'goal-task-2' }, + { + ...common, goal_id: 'goal-3', owner_id: 'owner-2', current_task_id: 'goal-task-3', + desired_state: 'running', pause_confirmed_at: null, claimed_at: new Date().toISOString(), + }, + { + ...common, goal_id: 'goal-4', owner_id: 'owner-2', current_task_id: 'goal-task-4', + desired_state: 'running', pause_confirmed_at: null, claimed_at: new Date().toISOString(), + }, + { + ...common, goal_id: 'goal-5', owner_id: 'owner-2', current_task_id: 'goal-task-5', + desired_state: 'running', pause_confirmed_at: null, claimed_at: new Date().toISOString(), + }, + { + ...common, goal_id: 'goal-6', owner_id: 'owner-2', current_task_id: 'goal-task-6', + desired_state: 'running', pause_confirmed_at: null, claimed_at: null, session_id: null, + }, + { + ...common, goal_id: 'goal-7', owner_id: 'owner-2', current_task_id: 'goal-task-7', + agent_alias: 'codex', agent_type: 'codex', + }, + { + ...common, goal_id: 'goal-8', owner_id: 'owner-2', current_task_id: 'goal-task-8', + launch_strategy: 'orchestrate', desired_state: 'running', pause_confirmed_at: null, + claimed_at: new Date().toISOString(), + }, + { + ...common, goal_id: 'goal-9', owner_id: 'owner-2', current_task_id: 'goal-task-9', + desired_state: 'running', pause_confirmed_at: null, claimed_at: new Date().toISOString(), + }, + ]); + await database('tasks').insert({ task_id: 'goal-task-9' }); + await database('task_history').insert({ task_id: 'goal-task-9', state: 'claude_execution', timestamp: new Date().toISOString() }); + await database('llm_executions').insert({ execution_id: 'goal-execution-9', task_id: 'goal-task-9' }); + await database('llm_execution_details').insert({ execution_id: 'goal-execution-9' }); + const routes = createGoalRoutes({ + db: database, + taskQueue: { + getJobs: async () => [], + add: async (name: string, data: Record, options: { jobId: string }) => { + queued.push({ name, data, options }); + }, + } as never, + redisClient: { + get: async (key: string) => key === 'agent:output:goal-task-1' ? [ + JSON.stringify({ type: 'assistant', timestamp: '2026-09-02T20:00:00Z', message: { + content: [{ type: 'tool_use', name: 'TodoWrite', input: { todos: [ + { id: 'todo-1', content: 'Inspect API', status: 'completed' }, + { id: 'todo-2', content: 'Run tests', status: 'in_progress' }, + ] } }], usage: { input_tokens: 12, output_tokens: 4 }, + } }), + ].join('\n') : null, + del: async () => 1, + } as never, + stopExecution: async taskId => { + stopped.push(taskId); + const attempt = (stopAttempts.get(taskId) ?? 0) + 1; + stopAttempts.set(taskId, attempt); + return (taskId === 'goal-task-2' || taskId === 'goal-task-9') && attempt === 1 + ? { success: true, containerStopped: false, removedQueuedJobs: 0, abortSignalled: true } as never + : { success: true, containerStopped: true, removedQueuedJobs: 0 } as never; + }, + getCapabilities: async options => { + capabilityRequests.push(options); + return []; + }, + }); + + const listed = response(); + await routes.list(request('owner-1'), listed.res); + assert.equal(listed.state.status, 200); + const listedGoals = (listed.state.body as { goals: Array<{ id: string; launchStrategy: string; initialPrompt: string; liveSummary: { currentTask: string; todos: unknown[]; tokenUsage: { input_tokens: number } } }> }).goals; + assert.deepEqual(listedGoals.map(goal => goal.id), ['goal-1']); + assert.equal(listedGoals[0].launchStrategy, 'direct'); + assert.equal(listedGoals[0].initialPrompt, '/goal Ship it\n\nSaved policy'); + assert.equal(listedGoals[0].liveSummary.currentTask, 'Run tests'); + assert.equal(listedGoals[0].liveSummary.todos.length, 2); + assert.equal(listedGoals[0].liveSummary.tokenUsage.input_tokens, 12); + assert.equal((await database('goals').where({ goal_id: 'goal-1' }).first()).artifacts_checked_at, '2000-01-01T00:00:00.000Z'); + + const invalidStrategy = response(); + await routes.create(request('owner-1', {}, { + repository: 'acme/repo', objective: 'Ship it', agentId: 'agent-1', model: 'gpt-5.6', + launchStrategy: 'planner', + }), invalidStrategy.res); + assert.equal(invalidStrategy.state.status, 400); + assert.deepEqual(invalidStrategy.state.body, { error: 'launchStrategy must be direct or orchestrate' }); + + const invalidCheckpointInterval = response(); + await routes.create(request('owner-1', {}, { + repository: 'acme/repo', objective: 'Ship it', agentId: 'agent-1', model: 'gpt-5.6', + launchStrategy: 'direct', checkpointIntervalMinutes: 4, + }), invalidCheckpointInterval.res); + assert.equal(invalidCheckpointInterval.state.status, 400); + assert.match((invalidCheckpointInterval.state.body as { error: string }).error, /integer from 5 to 120/); + + const orchestratedCheckpointInterval = response(); + await routes.create(request('owner-1', {}, { + repository: 'acme/repo', objective: 'Ship it', agentId: 'agent-1', model: 'gpt-5.6', + launchStrategy: 'orchestrate', checkpointIntervalMinutes: 15, + }), orchestratedCheckpointInterval.res); + assert.equal(orchestratedCheckpointInterval.state.status, 400); + assert.match((orchestratedCheckpointInterval.state.body as { error: string }).error, /only applies to direct goals/); + + const registry = AgentRegistry.getInstance(); + mock.method(registry, 'ensureInitialized', async () => {}); + mock.method(registry, 'getAgentById', (agentId: string) => ({ config: { + id: agentId, alias: agentId, type: agentId === 'codex-agent' ? 'codex' : 'claude', + supportedModels: ['gpt-5.6', 'gpt-5.6-fast'], + } } as never)); + const recheckRequest = request('owner-1'); + (recheckRequest as unknown as { query: Record }).query = { recheck: 'true' }; + const rechecked = response(); + await routes.capabilities(recheckRequest, rechecked.res); + assert.equal(rechecked.state.status, 200); + assert.deepEqual(capabilityRequests, [{ force: true }]); + + const oversizedCodexPrompt = response(); + const oversizedCodexRequest = request('owner-1', {}, { + repository: 'acme/repo', objective: '😀'.repeat(4_000), agentId: 'codex-agent', model: 'gpt-5.6', + launchStrategy: 'direct', + }); + oversizedCodexRequest.get = () => 'oversized-codex-prompt'; + await routes.create(oversizedCodexRequest, oversizedCodexPrompt.res); + assert.equal(oversizedCodexPrompt.state.status, 400); + assert.match((oversizedCodexPrompt.state.body as { error: string }).error, /Final Codex goal prompt/); + + await database('goals').where({ goal_id: 'goal-1' }).update({ + create_idempotency_key: 'create-key-1', + create_idempotency_operation: 'goal.create', + create_payload_hash: 'different-payload', + }); + const mismatchedCreate = response(); + const createRequest = request('owner-1', {}, { + repository: 'acme/repo', objective: 'Different goal', agentId: 'agent-1', model: 'gpt-5.6', + launchStrategy: 'direct', + }); + createRequest.get = () => 'create-key-1'; + await routes.create(createRequest, mismatchedCreate.res); + assert.equal(mismatchedCreate.state.status, 409); + + const hidden = response(); + await routes.get(request('owner-1', { goalId: 'goal-2' }), hidden.res); + assert.equal(hidden.state.status, 404); + + const taskHidden = response(); + let nextCalled = false; + await routes.requireGoalTaskOwnership( + request('owner-2', { taskId: 'goal-task-1' }), + taskHidden.res, + () => { nextCalled = true; }, + ); + assert.equal(taskHidden.state.status, 404); + assert.equal(nextCalled, false); + + const genericMutation = response(); + const mutationRequest = request('owner-1', { taskId: 'goal-task-1' }); + mutationRequest.method = 'POST'; + await routes.requireGoalTaskOwnership(mutationRequest, genericMutation.res, () => { nextCalled = true; }); + assert.equal(genericMutation.state.status, 409); + + const metricHidden = response(); + await routes.requireGoalTaskOwnership( + request('owner-2', { correlationId: 'goal-1' }), metricHidden.res, () => { nextCalled = true; }, + ); + assert.equal(metricHidden.state.status, 404); + + const continued = response(); + const inputRequest = request('owner-1', { goalId: 'goal-1' }, { message: 'Focus on the API first.' }); + inputRequest.get = () => 'owner-input-1'; + await routes.input(inputRequest, continued.res); + assert.equal(continued.state.status, 200); + assert.equal(queued.length, 1); + assert.equal(queued[0].name, 'processGoal'); + assert.deepEqual(queued[0].options, { jobId: 'goal-goal-1-3', attempts: 1 }); + assert.deepEqual({ ...queued[0].data, claimId: undefined }, { + goalId: 'goal-1', taskId: 'goal-task-1', repoOwner: 'acme', repoName: 'repo', + generation: 3, claimId: undefined, recovery: false, + }); + assert.equal(typeof queued[0].data.claimId, 'string'); + const savedInput = await database('goal_inputs').where({ goal_id: 'goal-1' }).first(); + assert.equal(savedInput.message, 'Focus on the API first.'); + assert.equal(savedInput.state, 'pending'); + const duplicateInput = response(); + await routes.input(inputRequest, duplicateInput.res); + assert.equal(duplicateInput.state.status, 200); + assert.equal(queued.length, 1); + assert.equal(Number((await database('goal_inputs').count('* as count').first()).count), 1); + const mismatchedInput = response(); + const mismatchedRequest = request('owner-1', { goalId: 'goal-1' }, { message: 'A different payload.' }); + mismatchedRequest.get = () => 'owner-input-1'; + await routes.input(mismatchedRequest, mismatchedInput.res); + assert.equal(mismatchedInput.state.status, 409); + const missingKeyInput = response(); + await routes.input(request('owner-1', { goalId: 'goal-1' }, { message: 'No key.' }), missingKeyInput.res); + assert.equal(missingKeyInput.state.status, 400); + const updated = await database('goals').where({ goal_id: 'goal-1' }).first(); + assert.equal(updated.session_id, 'thread-1'); + assert.equal(updated.current_task_id, 'goal-task-1'); + assert.equal(updated.worktree_path, '/worktrees/goal-1'); + assert.equal(updated.desired_state, 'running'); + + const manualCheckpoint = response(); + const checkpointRequest = request('owner-2', { goalId: 'goal-3' }); + checkpointRequest.get = () => 'owner-checkpoint-1'; + await routes.checkpoint(checkpointRequest, manualCheckpoint.res); + await routes.checkpoint(checkpointRequest, manualCheckpoint.res); + assert.equal(manualCheckpoint.state.status, 202); + assert.equal((manualCheckpoint.state.body as { goal: { checkpoint: { pending: boolean } } }).goal.checkpoint.pending, true); + const checkpointRows = await database('goal_checkpoints').where({ goal_id: 'goal-3' }); + assert.equal(checkpointRows.length, 1); + assert.equal(checkpointRows[0].kind, 'manual'); + assert.equal(checkpointRows[0].state, 'pending'); + + const frequencyRequest = request('owner-2', { goalId: 'goal-3' }, { minutes: 30 }); + frequencyRequest.get = () => 'owner-frequency-1'; + const frequency = response(); + await routes.requestCheckpointInterval(frequencyRequest, frequency.res); + await routes.requestCheckpointInterval(frequencyRequest, frequency.res); + assert.equal(frequency.state.status, 200); + assert.equal((await database('goals').where({ goal_id: 'goal-3' }).first()).checkpoint_interval_minutes, 30); + + const orchestratedCheckpoint = response(); + const orchestratedCheckpointRequest = request('owner-2', { goalId: 'goal-8' }); + orchestratedCheckpointRequest.get = () => 'owner-checkpoint-orchestrated'; + await routes.checkpoint(orchestratedCheckpointRequest, orchestratedCheckpoint.res); + assert.equal(orchestratedCheckpoint.state.status, 409); + assert.match((orchestratedCheckpoint.state.body as { error: string }).error, /only apply to direct goals/); + + const runningClaudeInput = response(); + const runningInputRequest = request('owner-2', { goalId: 'goal-3' }, { message: 'Apply this at a safe boundary.' }); + runningInputRequest.get = () => 'owner-running-input-1'; + await routes.input(runningInputRequest, runningClaudeInput.res); + assert.equal(runningClaudeInput.state.status, 200); + const boundary = await database('goals').where({ goal_id: 'goal-3' }).first(); + assert.equal(boundary.desired_state, 'paused'); + assert.equal(Boolean(boundary.resume_requested), true); + assert.equal(boundary.control_generation, 1); + assert.ok(stopped.includes('goal-task-3')); + + const preSessionInput = response(); + const preSessionRequest = request('owner-2', { goalId: 'goal-6' }, { message: 'Keep the initial goal, then apply this correction.' }); + preSessionRequest.get = () => 'owner-pre-session-input-1'; + await routes.input(preSessionRequest, preSessionInput.res); + assert.equal(preSessionInput.state.status, 200); + const preSessionBoundary = await database('goals').where({ goal_id: 'goal-6' }).first(); + assert.equal(preSessionBoundary.desired_state, 'running'); + assert.equal(preSessionBoundary.run_generation, 2); + assert.equal(preSessionBoundary.control_generation, 1); + assert.equal(stopped.includes('goal-task-6'), false); + assert.equal((await database('goal_inputs').where({ goal_id: 'goal-6' }).first()).state, 'pending'); + + const nativeResume = response(); + const nativeResumeRequest = request('owner-2', { goalId: 'goal-7' }); + nativeResumeRequest.get = () => 'owner-native-resume-1'; + await routes.resume(nativeResumeRequest, nativeResume.res); + assert.equal(nativeResume.state.status, 200); + assert.equal(queued.length, 2); + const nativeResumeRecord = await database('goal_inputs').where({ goal_id: 'goal-7', operation: 'goal.resume' }).first(); + assert.equal(nativeResumeRecord.kind, 'control'); + assert.equal(nativeResumeRecord.state, 'delivered'); + assert.equal(nativeResumeRecord.message, ''); + + const pauseRequest = request('owner-2', { goalId: 'goal-4' }); + pauseRequest.get = () => 'owner-pause-1'; + await routes.pause(pauseRequest, response().res); + await routes.pause(pauseRequest, response().res); + assert.equal(stopped.filter(taskId => taskId === 'goal-task-4').length, 2); + + const modelRequest = request('owner-2', { goalId: 'goal-5' }, { model: 'gpt-5.6-fast' }); + modelRequest.get = () => 'owner-model-1'; + await routes.requestModel(modelRequest, response().res); + const modelBoundary = await database('goals').where({ goal_id: 'goal-5' }).first(); + assert.equal(modelBoundary.requested_model, 'gpt-5.6-fast'); + assert.equal(modelBoundary.desired_state, 'paused'); + assert.equal(Boolean(modelBoundary.resume_requested), true); + assert.equal(modelBoundary.control_generation, 1); + assert.ok(stopped.includes('goal-task-5')); + + const cancelled = response(); + const cancelRequest = request('owner-2', { goalId: 'goal-2' }); + cancelRequest.get = () => 'owner-cancel-1'; + await routes.cancel(cancelRequest, cancelled.res); + assert.equal((await database('goals').where({ goal_id: 'goal-2' }).first()).result_state, null); + await routes.cancel(cancelRequest, cancelled.res); + assert.equal(cancelled.state.status, 200); + assert.ok(stopped.includes('goal-task-2')); + assert.equal(stopped.filter(taskId => taskId === 'goal-task-2').length, 2); + assert.equal((await database('goals').where({ goal_id: 'goal-2' }).first()).result_state, 'cancelled'); + + const hiddenDelete = response(); + await routes.remove(request('owner-1', { goalId: 'goal-9' }), hiddenDelete.res); + assert.equal(hiddenDelete.state.status, 404); + assert.ok(await database('goals').where({ goal_id: 'goal-9' }).first()); + + const stoppingDelete = response(); + await routes.remove(request('owner-2', { goalId: 'goal-9' }), stoppingDelete.res); + assert.equal(stoppingDelete.state.status, 409); + assert.equal((await database('goals').where({ goal_id: 'goal-9' }).first()).desired_state, 'cancelled'); + + const deleted = response(); + await routes.remove(request('owner-2', { goalId: 'goal-9' }), deleted.res); + assert.equal(deleted.state.status, 204); + assert.equal(await database('goals').where({ goal_id: 'goal-9' }).first(), undefined); + assert.equal(await database('tasks').where({ task_id: 'goal-task-9' }).first(), undefined); + assert.equal(await database('task_history').where({ task_id: 'goal-task-9' }).first(), undefined); + assert.equal(await database('llm_executions').where({ task_id: 'goal-task-9' }).first(), undefined); + assert.equal(await database('llm_execution_details').where({ execution_id: 'goal-execution-9' }).first(), undefined); + } finally { + await database.destroy(); + await closeConnection(); + } +}); diff --git a/packages/api/test/goalTaskIsolation.test.ts b/packages/api/test/goalTaskIsolation.test.ts new file mode 100644 index 000000000..39f567cae --- /dev/null +++ b/packages/api/test/goalTaskIsolation.test.ts @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import knex from 'knex'; +import { getTasksFromDb } from '../routes/taskHelpers.js'; + +test('generic task lists exclude native goal backing tasks', async () => { + const database = knex({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + try { + await database.schema.createTable('tasks', table => { + table.string('task_id'); table.string('repository'); table.string('task_type'); + table.timestamp('created_at'); table.text('initial_job_data'); table.text('final_result'); + table.integer('issue_number'); table.integer('pr_number'); + }); + await database.schema.createTable('task_history', table => { + table.string('task_id'); table.string('state'); table.timestamp('timestamp'); table.text('reason'); + }); + await database.schema.createTable('plan_issues', table => { + table.string('task_id'); table.string('status'); + }); + await database.schema.createTable('llm_executions', table => { + table.increments('execution_id'); table.string('task_id'); table.text('analysis_report'); + }); + const now = new Date().toISOString(); + await database('tasks').insert([ + { task_id: 'ordinary-task', repository: 'acme/widget', task_type: 'issue', created_at: now }, + { task_id: 'legacy-task', repository: 'acme/widget', task_type: null, created_at: now }, + { task_id: 'goal-task', repository: 'acme/widget', task_type: 'goal', created_at: now }, + ]); + await database('task_history').insert([ + { task_id: 'ordinary-task', state: 'processing', timestamp: now }, + { task_id: 'legacy-task', state: 'processing', timestamp: now }, + { task_id: 'goal-task', state: 'processing', timestamp: now }, + ]); + + const result = await getTasksFromDb({ + db: database, status: 'all', repository: 'all', limit: 100, offset: 0, + }); + assert.equal(result.total, 2); + assert.deepEqual(new Set((result.tasks as Array<{ id: string }>).map(task => task.id)), new Set(['ordinary-task', 'legacy-task'])); + } finally { + await database.destroy(); + } +}); diff --git a/packages/api/test/queueBroadcaster.test.ts b/packages/api/test/queueBroadcaster.test.ts new file mode 100644 index 000000000..bd8b415c3 --- /dev/null +++ b/packages/api/test/queueBroadcaster.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { QUEUE_STATS_UPDATE } from '@propr/shared'; +import { QueueBroadcaster } from '../services/queueBroadcaster.js'; + +test('queue broadcasts active goal jobs separately from the aggregate active count', async () => { + const emitted: Array<{ room: string; event: string; payload: unknown }> = []; + const io = { + to: (room: string) => ({ + emit: (event: string, payload: unknown) => emitted.push({ room, event, payload }), + }), + }; + const queue = { + getWaitingCount: async () => 2, + getJobs: async (states: string[]) => { + assert.deepEqual(states, ['active']); + return [ + { name: 'processGitHubIssue' }, + { name: 'processGoal' }, + { name: 'processGoal' }, + ]; + }, + getCompletedCount: async () => 5, + getFailedCount: async () => 1, + getDelayedCount: async () => 3, + }; + + await new QueueBroadcaster(io as never, queue as never).broadcastQueueStats(); + + assert.equal(emitted.length, 1); + assert.equal(emitted[0].room, 'queue:stats'); + assert.equal(emitted[0].event, QUEUE_STATS_UPDATE); + assert.deepEqual((emitted[0].payload as { stats: unknown }).stats, { + waiting: 2, + active: 3, + activeGoals: 2, + completed: 5, + failed: 1, + delayed: 3, + total: 14, + }); +}); diff --git a/packages/api/test/queueRoutes.test.ts b/packages/api/test/queueRoutes.test.ts index eab73c089..d2fe94891 100644 --- a/packages/api/test/queueRoutes.test.ts +++ b/packages/api/test/queueRoutes.test.ts @@ -78,10 +78,16 @@ test('/api/queue/stats represents every active job once and exposes only safe pr timestamp: Date.parse('2026-08-14T20:05:00.000Z'), data: { repository: 'integry/propr', taskDescription: 'Sensitive imported task instructions' }, }, + { + id: 'goal-job-7', + name: 'processGoal', + timestamp: Date.parse('2026-08-14T20:06:00.000Z'), + data: { repoOwner: 'integry', repoName: 'propr', title: 'Ship native goals' }, + }, { id: 'issue-job-1', name: 'processGitHubIssue', - timestamp: Date.parse('2026-08-14T20:06:00.000Z'), + timestamp: Date.parse('2026-08-14T20:07:00.000Z'), data: { repoOwner: 'integry', repoName: 'propr', number: 1906 }, }, ]; @@ -96,7 +102,8 @@ test('/api/queue/stats represents every active job once and exposes only safe pr assert.deepEqual(requestedStates, [['active']], 'waiting and delayed jobs are not Running'); assert.deepEqual(body(), { waiting: 3, - active: 6, + active: 7, + activeGoals: 1, activeJobs: [ { id: 'issue-job-1', @@ -143,11 +150,18 @@ test('/api/queue/stats represents every active job once and exposes only safe pr repository: 'integry/propr', createdAt: '2026-08-14T20:05:00.000Z', }, + { + id: 'goal-job-7', + name: 'processGoal', + title: 'Ship native goals', + repository: 'integry/propr', + createdAt: '2026-08-14T20:06:00.000Z', + }, ], completed: 8, failed: 1, delayed: 2, - total: 20, + total: 21, }); }); diff --git a/packages/api/test/webPushDispatcher.test.ts b/packages/api/test/webPushDispatcher.test.ts index f14121021..41ea19401 100644 --- a/packages/api/test/webPushDispatcher.test.ts +++ b/packages/api/test/webPushDispatcher.test.ts @@ -359,23 +359,23 @@ describe('Web Push dispatcher', { concurrency: false }, () => { const run = worker.runOnce(); await started; - await database('push_subscriptions') - .where({ subscription_id: subscription.id }) - .update({ - p256dh_key: refreshedPublicKey, - auth_key: refreshedAuthKey, - }); - const refreshed = await database('push_subscriptions') - .where({ subscription_id: subscription.id }) - .first(); - assert.notEqual(refreshed.updated_at, original.updated_at); - assert.equal(refreshed.p256dh_key, refreshedPublicKey); - assert.equal(refreshed.auth_key, refreshedAuthKey); - returnStaleResponse(); + try { + await database('push_subscriptions') + .where({ subscription_id: subscription.id }) + .update({ + p256dh_key: refreshedPublicKey, + auth_key: refreshedAuthKey, + }); + } finally { + // Never strand the dispatcher behind the test gate when the concurrent + // database update fails; teardown would otherwise wait for it forever. + returnStaleResponse(); + } assert.equal(await run, 1); const stored = await database('push_subscriptions') .where({ subscription_id: subscription.id }) .first(); + assert.notEqual(stored.updated_at, original.updated_at); assert.equal(stored.revoked_at, null); assert.equal(stored.p256dh_key, refreshedPublicKey); assert.equal(stored.auth_key, refreshedAuthKey); diff --git a/packages/core/src/agents/AgentRegistry.ts b/packages/core/src/agents/AgentRegistry.ts index 2cb7fc2c8..b398fc3b0 100644 --- a/packages/core/src/agents/AgentRegistry.ts +++ b/packages/core/src/agents/AgentRegistry.ts @@ -6,18 +6,13 @@ import { executeDockerCommand } from '../claude/docker/dockerExecutor.js'; import { closeConnection } from '../db/connection.js'; import { shutdownQueue } from '../queue/taskQueue.js'; import { loadAgentRuntimePackageState } from './runtime/agentRuntimePackages.js'; +import { GoalCapabilityProbe, type GoalCapability } from './goalCapabilities.js'; +import type { AgentRegistryOperationalStatus } from './agentRegistryTypes.js'; import { SyntheticAgentRegistry, type BeginSyntheticRoutingOptions, type SyntheticRoutingSession } from './SyntheticAgentRegistry.js'; import { createAgentFromConfig } from './createAgentFromConfig.js'; import { resolveDefaultAgentConfig, resolveUnifiedAgentImage } from './agentImagePreparation.js'; -export interface AgentRegistryOperationalStatus { - unifiedAgentImage: { - status: 'ready' | 'unavailable'; - imageTag?: string; - error?: string; - recordedAt?: string; - }; -} +export type { AgentRegistryOperationalStatus } from './agentRegistryTypes.js'; const RUNTIME_PACKAGE_STATE_CHECK_INTERVAL_MS = 5000; const UNIFIED_AGENT_IMAGE_RETRY_INTERVAL_MS = 60_000; @@ -41,6 +36,7 @@ export class AgentRegistry { private pendingBackgroundRefresh: Promise | null = null; private unavailableUnifiedAgentImage: { imageTag?: string; error: string; recordedAt: string } | null = null; private unifiedAgentImageRetryTimer: NodeJS.Timeout | null = null; + private goalCapabilityProbe = new GoalCapabilityProbe(); private syntheticAgents = new SyntheticAgentRegistry(this.agents, this.agentsByAlias); private constructor() { @@ -135,6 +131,7 @@ export class AgentRegistry { // that can still use the previous image. this.agents.clear(); this.agentsByAlias.clear(); + this.goalCapabilityProbe.clear(); for (const config of configs) { if (!config.enabled) { logger.debug({ agentAlias: config.alias }, 'Skipping disabled agent'); @@ -260,6 +257,11 @@ export class AgentRegistry { return Array.from(this.agents.values()); } + /** Capability-probes the exact configured image using non-inference introspection. */ + async getGoalCapabilities(options: { force?: boolean } = {}): Promise { + return this.goalCapabilityProbe.getAll(this.getAllAgents(), options); + } + /** * Gets all agent configurations (including disabled ones from config). */ @@ -466,6 +468,7 @@ export class AgentRegistry { this.unavailableUnifiedAgentImage = null; this.agents.clear(); this.agentsByAlias.clear(); + this.goalCapabilityProbe.clear(); const agent = new ClaudeAgent(result.config); this.agents.set(result.config.id, agent); this.agentsByAlias.set(result.config.alias, agent); diff --git a/packages/core/src/agents/agentRegistryTypes.ts b/packages/core/src/agents/agentRegistryTypes.ts new file mode 100644 index 000000000..007f6fa35 --- /dev/null +++ b/packages/core/src/agents/agentRegistryTypes.ts @@ -0,0 +1,8 @@ +export interface AgentRegistryOperationalStatus { + unifiedAgentImage: { + status: 'ready' | 'unavailable'; + imageTag?: string; + error?: string; + recordedAt?: string; + }; +} diff --git a/packages/core/src/agents/goalCapabilities.ts b/packages/core/src/agents/goalCapabilities.ts new file mode 100644 index 000000000..020dae35d --- /dev/null +++ b/packages/core/src/agents/goalCapabilities.ts @@ -0,0 +1,246 @@ +import { executeDockerCommand, type ExecutionResult } from '../claude/docker/dockerExecutor.js'; +import { parseAntigravityJsonl } from './impl/utils/antigravityOutputParser.js'; +import type { Agent, AgentType } from './types.js'; + +export interface GoalCapability { + agentId: string; + agentAlias: string; + agentType: AgentType; + goalCapable: boolean; + lifecycle: { + launch: 'native-goal' | 'goal-prompt'; + resume: 'native-goal' | 'whole-session'; + runningInput: 'live-steer' | 'safe-boundary-resume'; + } | null; + controls: { + liveInput: boolean; + inputAtBoundary: boolean; + modelAtBoundary: boolean; + pauseAtBoundary: boolean; + }; + reason?: string; +} + +type DockerExecutor = ( + command: string, + args: string[], + options?: Parameters[2], +) => Promise; + +const REQUIRED_CODEX_GOAL_METHODS = [ + 'thread/goal/get', + 'thread/goal/set', + 'thread/goal/clear', +] as const; +const FAILURE_CACHE_TTL_MS = 30_000; + +function controlsFor(agent: Agent): GoalCapability['controls'] { + return agent.config.type === 'codex' + ? { liveInput: true, inputAtBoundary: true, modelAtBoundary: true, pauseAtBoundary: true } + : { liveInput: false, inputAtBoundary: true, modelAtBoundary: true, pauseAtBoundary: true }; +} + +function lifecycleFor(agent: Agent): NonNullable { + return agent.config.type === 'codex' + ? { launch: 'native-goal', resume: 'native-goal', runningInput: 'live-steer' } + : { launch: 'goal-prompt', resume: 'whole-session', runningInput: 'safe-boundary-resume' }; +} + +function supportedCapability(agent: Agent): GoalCapability { + return { + agentId: agent.config.id, + agentAlias: agent.config.alias, + agentType: agent.config.type, + goalCapable: true, + lifecycle: lifecycleFor(agent), + controls: controlsFor(agent), + }; +} + +export const GOAL_CAPABILITY_COMMANDS: Partial> = { + claude: 'claude', + codex: 'codex', + antigravity: 'agy', +}; + +interface JsonMessage { + id?: number; + type?: string; + subtype?: string; + session_id?: string; + result?: unknown; + error?: { code?: number; message?: string }; +} + +function parseJsonLines(output: string): JsonMessage[] { + const messages: JsonMessage[] = []; + for (const line of output.split('\n')) { + try { messages.push(JSON.parse(line) as JsonMessage); } catch { /* provider diagnostic */ } + } + return messages; +} + +/** Retained for consumers that inspect recorded legacy handshakes. New probes use the schema. */ +export function codexHandshakeSupportsNativeGoal(output: string): boolean { + const messages = parseJsonLines(output); + const initialized = messages.some(message => message.id === 1 && message.result !== undefined && !message.error); + const goalProbes = [2, 3, 4, 5].map(id => messages.find(message => message.id === id)); + if (!initialized || goalProbes.some(probe => !probe)) return false; + return goalProbes.every(probe => probe?.error?.code !== -32601 + && !/method not found|unknown method/i.test(probe?.error?.message || '')); +} + +function collectJsonStrings(value: unknown, strings: Set): void { + if (typeof value === 'string') { + strings.add(value); + return; + } + if (Array.isArray(value)) { + for (const item of value) collectJsonStrings(item, strings); + return; + } + if (value && typeof value === 'object') { + for (const item of Object.values(value as Record)) collectJsonStrings(item, strings); + } +} + +/** Verify all native goal methods from Codex's generated experimental protocol schema. */ +export function codexSchemaSupportsNativeGoal(output: string): boolean { + try { + const strings = new Set(); + collectJsonStrings(JSON.parse(output), strings); + return REQUIRED_CODEX_GOAL_METHODS.every(method => strings.has(method)); + } catch { + return false; + } +} + +function cliHelpHasOption(output: string, option: string): boolean { + const escaped = option.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^\\s*(?:-[^\\s,]+,?\\s+)?${escaped}(?:[\\s=<\\[]|$)`, 'm').test(output); +} + +/** Claude goal mode needs persisted noninteractive sessions and exact-session resume. */ +export function claudeHelpSupportsWholeSession(output: string): boolean { + return ['--print', '--resume', '--output-format', '--no-session-persistence'] + .every(option => cliHelpHasOption(output, option)); +} + +/** Antigravity goal mode needs noninteractive output and exact-conversation resume. */ +export function antigravityHelpSupportsWholeSession(output: string): boolean { + return ['--print', '--conversation', '--output-format', '--disable-slash-commands'] + .every(option => cliHelpHasOption(output, option)); +} + +export function claudeSessionIdentity(output: string): string | undefined { + const init = parseJsonLines(output).find(message => + message.type === 'system' && message.subtype === 'init'); + return init?.session_id; +} + +export function antigravityConversationIdentity(output: string): string | undefined { + const parsed = parseAntigravityJsonl(output); + return parsed.hasStreamEnvelopes && parsed.terminalStatus === 'success' && !parsed.protocolError + ? parsed.conversationId + : undefined; +} + +function unsupportedCapability(agent: Agent, reason: string): GoalCapability { + return { + agentId: agent.config.id, + agentAlias: agent.config.alias, + agentType: agent.config.type, + goalCapable: false, + lifecycle: null, + controls: { liveInput: false, inputAtBoundary: false, modelAtBoundary: false, pauseAtBoundary: false }, + reason, + }; +} + +function introspectionOutput(result: ExecutionResult): string { + return `${result.stdout}\n${result.stderr}`; +} + +async function probeCodex(agent: Agent, executor: DockerExecutor): Promise { + const schemaCommand = [ + 'set -eu', + 'schema_dir="$(mktemp -d)"', + 'codex app-server generate-json-schema --experimental --out "$schema_dir" >/dev/null', + 'cat "$schema_dir/ClientRequest.json"', + ].join('; '); + const result = await executor('docker', [ + 'run', '--rm', '--network', 'none', '--entrypoint', '/bin/sh', + agent.config.dockerImage, '-c', schemaCommand, + ], { timeout: 30_000 }); + return result.exitCode === 0 && codexSchemaSupportsNativeGoal(result.stdout) + ? supportedCapability(agent) + : unsupportedCapability(agent, 'Pinned Codex App Server schema does not expose native goal get, set, and clear methods'); +} + +async function probeClaude(agent: Agent, executor: DockerExecutor): Promise { + const result = await executor('docker', [ + 'run', '--rm', '--network', 'none', '--entrypoint', 'claude', + agent.config.dockerImage, '--help', + ], { timeout: 30_000 }); + return result.exitCode === 0 && claudeHelpSupportsWholeSession(introspectionOutput(result)) + ? supportedCapability(agent) + : unsupportedCapability(agent, 'Pinned Claude runtime does not expose persisted noninteractive sessions with exact --resume support'); +} + +async function probeAntigravity(agent: Agent, executor: DockerExecutor): Promise { + const result = await executor('docker', [ + 'run', '--rm', '--network', 'none', '--entrypoint', 'agy', + agent.config.dockerImage, '--help', + ], { timeout: 30_000 }); + return result.exitCode === 0 && antigravityHelpSupportsWholeSession(introspectionOutput(result)) + ? supportedCapability(agent) + : unsupportedCapability(agent, 'Pinned Antigravity runtime does not expose noninteractive --conversation resume and slash-command support'); +} + +/** Capability-probe the configured runtime without authentication or provider inference. */ +export async function probeGoalCapability( + agent: Agent, + executor: DockerExecutor = executeDockerCommand, +): Promise { + if (!agent.goalCapable) return unsupportedCapability(agent, 'Provider does not implement goal-session mode'); + try { + if (agent.config.type === 'codex') return await probeCodex(agent, executor); + if (agent.config.type === 'claude') return await probeClaude(agent, executor); + if (agent.config.type === 'antigravity') return await probeAntigravity(agent, executor); + return unsupportedCapability(agent, 'Provider has no proven goal-session transport'); + } catch (error) { + return unsupportedCapability(agent, `Capability introspection failed: ${(error as Error).message}`); + } +} + +interface CachedGoalCapability { + capability: GoalCapability; + expiresAt: number; +} + +export class GoalCapabilityProbe { + private cache = new Map(); + + constructor( + private readonly failureCacheTtlMs = FAILURE_CACHE_TTL_MS, + private readonly now: () => number = Date.now, + private readonly probe: (agent: Agent) => Promise = probeGoalCapability, + ) {} + + clear(): void { + this.cache.clear(); + } + + async getAll(agents: Agent[], options: { force?: boolean } = {}): Promise { + return Promise.all(agents.map(async agent => { + const cached = this.cache.get(agent.config.id); + if (!options.force && cached && cached.expiresAt > this.now()) return cached.capability; + const capability = await this.probe(agent); + this.cache.set(agent.config.id, { + capability, + expiresAt: capability.goalCapable ? Number.POSITIVE_INFINITY : this.now() + this.failureCacheTtlMs, + }); + return capability; + })); + } +} diff --git a/packages/core/src/agents/impl/AntigravityAgent.ts b/packages/core/src/agents/impl/AntigravityAgent.ts index 1c8534a3c..6c119a03c 100644 --- a/packages/core/src/agents/impl/AntigravityAgent.ts +++ b/packages/core/src/agents/impl/AntigravityAgent.ts @@ -29,21 +29,17 @@ import fs from 'fs'; import path from 'path'; import { randomBytes } from 'node:crypto'; import { resolveAgentTerminationReason } from '../termination.js'; -import { createContainerExecutionId } from './utils/containerExecutionId.js'; +import { buildAntigravityDockerArgs } from './utils/antigravityDockerArgsBuilder.js'; import { - buildAntigravityRepositoryScoutMcpConfig, - buildAntigravityRepositoryScoutPermissions, - REPOSITORY_SCOUT_CONTAINER_ROOT, -} from './utils/repositoryScoutMcpServer.js'; + readBoundedProviderOutputFile, +} from './utils/boundedProviderOutput.js'; // Re-export UsageLimitError for convenience export { UsageLimitError }; const ANALYSIS_AGENT_TANK_TIMEOUT_MS = parseInt(process.env.ANALYSIS_AGENT_TANK_TIMEOUT_MS || '2000', 10); -const ANTIGRAVITY_CONTAINER_SOURCE_CONFIG_PATH = '/home/node/.gemini-source'; const DEFAULT_ANTIGRAVITY_TRANSCRIPT_ROOT = '/tmp/git-processor/propr-cache/transcripts/antigravity'; -const GITHUB_CREDENTIAL_ENV_PATTERN = /^(?:GH|GITHUB)_.*(?:TOKEN|KEY|SECRET|PASSWORD|PAT|PRIVATE_KEY)$/; function isSuccessfulAnalysisResult( result: { timedOut?: boolean; exitCode: number | null }, @@ -59,33 +55,13 @@ function resolveAntigravityExecutionError(terminalStatus: 'success' | 'error' | function resolveAntigravityEvidenceConflict(stdoutModel: string | undefined, transcriptModel: string | undefined, stdoutConversation: string | undefined, transcriptConversation: string | undefined): string | undefined { if (stdoutConversation && transcriptConversation && stdoutConversation !== transcriptConversation) return `Conflicting Antigravity conversation identities: stdout reported "${stdoutConversation}" but transcript reported "${transcriptConversation}"`; const stdout = stdoutModel && normalizeAntigravityModelId(stdoutModel); const transcript = transcriptModel && normalizeAntigravityModelId(transcriptModel); return stdout && transcript && stdout !== transcript ? `Conflicting Antigravity model identities: stdout reported "${stdout}" but transcript reported "${transcript}"` : undefined; } -function buildAgentEnvironmentArgs( - repositoryInspection: boolean, - ...sources: Array | undefined> -): string[] { - const args: string[] = []; - for (const source of sources) { - if (!source) continue; - for (const [key, value] of Object.entries(source)) { - if (repositoryInspection && GITHUB_CREDENTIAL_ENV_PATTERN.test(key.toUpperCase())) continue; - args.push('-e', `${key}=${value}`); - } - } - return args; -} - -function assertRepositoryInspectionMode(repositoryInspection: boolean, readOnlyWorkspace: boolean): void { - if (repositoryInspection && !readOnlyWorkspace) { - throw new Error('Repository inspection requires a read-only workspace'); - } -} - function getAntigravityTranscriptRoot(): string { return process.env.PROPR_ANTIGRAVITY_TRANSCRIPT_ROOT || DEFAULT_ANTIGRAVITY_TRANSCRIPT_ROOT; } export class AntigravityAgent implements Agent { readonly config: AgentConfig; + readonly goalCapable = true; private readonly timeoutMs: number; constructor(config: AgentConfig) { @@ -97,10 +73,6 @@ export class AntigravityAgent implements Agent { return 'antigravity'; } - private getContainerConfigPath(): string { - return ANTIGRAVITY_CONTAINER_SOURCE_CONFIG_PATH; - } - private getCliCommand(): string { return 'agy'; } @@ -123,7 +95,7 @@ export class AntigravityAgent implements Agent { } async executeTask(options: AgentTaskOptions): Promise { - const { worktreePath, issueRef, prompt: customPrompt, model, isRetry = false, retryReason, onSessionId, onContainerId, githubToken, environment, taskId, prNumber, metadata } = options; + const { worktreePath, issueRef, prompt: customPrompt, model, isRetry = false, retryReason, onSessionId, onContainerId, githubToken, environment, taskId, prNumber, executionMode = 'task', resumeSessionId, resumeConversationId, metadata } = options; const startTime = Date.now(); const effectiveModel = model || this.config.defaultModel; const transcriptPath = this.createTransientTranscriptPath(taskId); @@ -134,10 +106,12 @@ export class AntigravityAgent implements Agent { }, isRetry ? 'Starting Antigravity agent execution (RETRY)...' : 'Starting Antigravity agent execution...'); try { - const prompt = this.buildPromptWithRetryContext(customPrompt, isRetry, retryReason); - await setWorktreeOwnership(worktreePath, issueRef.number); + const prompt = executionMode === 'goal' ? customPrompt : this.buildPromptWithRetryContext(customPrompt, isRetry, retryReason); + await setWorktreeOwnership(worktreePath, issueRef.number, { + protectGitMetadata: executionMode === 'goal' && environment?.PROPR_GOAL_LAUNCH_STRATEGY === 'direct', + }); const worktreeGitContent = verifyWorktreeStructure(worktreePath, issueRef.number); - const dockerArgs = this.buildDockerArgs({ worktreePath, githubToken, modelName: effectiveModel, issueNumber: issueRef.number, environment, taskId, transcriptPath }); + const dockerArgs = this.buildDockerArgs({ worktreePath, githubToken, modelName: effectiveModel, issueNumber: issueRef.number, environment, taskId, transcriptPath, executionMode, resumeConversationId: resumeConversationId || resumeSessionId }); const { result, usageMetrics } = await executeWithUsageTracking( this.getRuntimeName(), @@ -148,7 +122,7 @@ export class AntigravityAgent implements Agent { ); const executionTime = Date.now() - startTime; - return this.processExecutionResult({ result, executionTime, issueRef, effectiveModel, prompt, worktreePath, worktreeGitContent, onSessionId, taskId, prNumber, isRetry, retryReason, usageMetrics, transcriptPath, metadata }); + return this.processExecutionResult({ result, executionTime, issueRef, effectiveModel, prompt, worktreePath, worktreeGitContent, taskId, prNumber, isRetry, retryReason, usageMetrics, transcriptPath, metadata }); } catch (error) { return this.handleExecutionError(error, Date.now() - startTime, issueRef, effectiveModel); } finally { @@ -166,15 +140,14 @@ export class AntigravityAgent implements Agent { private async processExecutionResult(opts: { result: { stdout: string; stderr: string; exitCode: number | null; timedOut?: boolean }; executionTime: number; issueRef: { number: number; repoOwner: string; repoName: string }; effectiveModel: string | undefined; - prompt: string; worktreePath: string; worktreeGitContent: string | null; onSessionId?: (sessionId: string, conversationId?: string) => void; + prompt: string; worktreePath: string; worktreeGitContent: string | null; taskId?: string; prNumber?: number; isRetry?: boolean; retryReason?: string; usageMetrics?: UsageTrackingMetrics | null; transcriptPath?: string; metadata?: Record; }): Promise { - const { result, executionTime, issueRef, effectiveModel, prompt, worktreePath, worktreeGitContent, onSessionId, taskId, prNumber, isRetry, retryReason, usageMetrics, transcriptPath, metadata } = opts; + const { result, executionTime, issueRef, effectiveModel, prompt, worktreePath, worktreeGitContent, taskId, prNumber, isRetry, retryReason, usageMetrics, transcriptPath, metadata } = opts; logger.info({ issueNumber: issueRef.number, repository: `${issueRef.repoOwner}/${issueRef.repoName}`, executionTime, outputLength: result.stdout?.length || 0, success: result.exitCode === 0, exitCode: result.exitCode, agentAlias: this.config.alias }, 'Antigravity agent execution completed'); - const parsed = this.resolveSessionOutput(result.stdout, transcriptPath, onSessionId); - const { response } = await parsed; + const { response } = await this.resolveSessionOutput(result.stdout, transcriptPath); const finalTokenUsage = this.resolveTokenUsage(response.tokenUsage, prompt, response.summary, response.rawConversationLog); const modelIdentity = resolveAntigravityModelIdentity(response.modelUsed, effectiveModel, response.hasStreamEnvelopes); const resolvedModel = modelIdentity.modelUsed; @@ -184,7 +157,8 @@ export class AntigravityAgent implements Agent { const agentResult: AgentExecutionResult = { success, executionTimeMs: executionTime, logs: result.stdout + (result.stderr ? `\n\nSTDERR:\n${result.stderr}` : ''), - exitCode: result.exitCode, rawOutput: result.stdout, modelUsed: resolvedModel, modifiedFiles: [], + exitCode: result.exitCode, rawOutput: result.stdout, modelUsed: resolvedModel, + providerModel: response.modelUsed, modifiedFiles: [], commitMessage: null, summary: response.summary ?? undefined, prompt, sessionId: response.sessionId, conversationId: response.conversationId, conversationLog: response.conversationLog, tokenUsage: finalTokenUsage, usageMetrics: usageMetrics ?? undefined, error: success ? undefined : result.stderr || executionError || 'Antigravity execution failed', @@ -198,7 +172,7 @@ export class AntigravityAgent implements Agent { return agentResult; } - private async resolveSessionOutput(stdout: string, transcriptPath?: string, onSessionId?: (sessionId: string, conversationId?: string) => void) { + private async resolveSessionOutput(stdout: string, transcriptPath?: string) { const parsedOutput = parseAntigravityJsonl(stdout); const sessionOutput = await this.readTransientSessionOutput(transcriptPath, parsedOutput.sessionId); const sessionId = sessionOutput.sessionId || parsedOutput.sessionId; @@ -211,7 +185,6 @@ export class AntigravityAgent implements Agent { const evidenceConflict = resolveAntigravityEvidenceConflict(parsedOutput.modelUsed, sessionOutput.modelUsed, parsedOutput.conversationId, sessionOutput.conversationId); const modelUsed = evidenceConflict ? undefined : parsedOutput.modelUsed || sessionOutput.modelUsed; const terminalStatus: 'success' | 'error' | undefined = parsedOutput.terminalStatus === 'error' || sessionOutput.terminalStatus === 'error' ? 'error' : parsedOutput.terminalStatus || sessionOutput.terminalStatus; const protocolError = resolveAntigravityProtocolError(parsedOutput.terminalStatus, parsedOutput.protocolError, parsedOutput.hasStreamEnvelopes) ?? resolveAntigravityProtocolError(sessionOutput.terminalStatus, sessionOutput.protocolError, sessionOutput.hasStreamEnvelopes) ?? evidenceConflict; const hasStreamEnvelopes = parsedOutput.hasStreamEnvelopes || sessionOutput.hasStreamEnvelopes; - if (sessionId && onSessionId) onSessionId(sessionId, conversationId); // rawConversationLog (full agentic trace: file views, searches, command // output, code edits) is kept for token estimation; conversationLog is // filtered and converted to the Claude-shaped representation consumed by @@ -237,7 +210,7 @@ export class AntigravityAgent implements Agent { private async readTransientSessionOutput(transcriptPath: string | undefined, parsedSessionId?: string): Promise<{ sessionId: string | undefined; conversationId?: string; summary: string | undefined; conversationLog: AntigravityOutputEvent[]; tokenUsage?: TokenUsage; modelUsed?: string; terminalStatus?: 'success' | 'error'; protocolError?: string; hasStreamEnvelopes: boolean }> { if (!transcriptPath) return { sessionId: parsedSessionId, summary: undefined, conversationLog: [], hasStreamEnvelopes: false }; try { - const transcript = await fs.promises.readFile(transcriptPath, 'utf8'); + const transcript = await readBoundedProviderOutputFile(transcriptPath); const parsed = parseAntigravityJsonl(transcript); return { sessionId: parsed.sessionId || parsedSessionId, @@ -433,33 +406,17 @@ export class AntigravityAgent implements Agent { return ['set -e', `exec ${this.getCliCommand()} ${safetyArgs} "$@"`].join('\n'); } - private buildDockerArgs(params: { worktreePath: string; githubToken: string; modelName?: string; issueNumber: number; environment?: Record; taskId?: string; executionType?: string; transcriptPath?: string; readOnlyWorkspace?: boolean; repositoryInspection?: boolean }): string[] { - const { worktreePath, githubToken, modelName, issueNumber, environment, taskId, executionType, transcriptPath, readOnlyWorkspace = false, repositoryInspection = false } = params; - assertRepositoryInspectionMode(repositoryInspection, readOnlyWorkspace); + private buildDockerArgs(params: { worktreePath: string; githubToken: string; modelName?: string; issueNumber: number; environment?: Record; taskId?: string; executionType?: string; transcriptPath?: string; readOnlyWorkspace?: boolean; repositoryInspection?: boolean; executionMode?: 'task' | 'goal'; resumeConversationId?: string }): string[] { + const { worktreePath, githubToken, modelName, issueNumber, environment, taskId, executionType, transcriptPath, readOnlyWorkspace = false, repositoryInspection = false, executionMode = 'task', resumeConversationId } = params; const configPath = this.getHostConfigPath(); - const envVars = buildAgentEnvironmentArgs(repositoryInspection, this.config.envVars, environment); - const shortTaskId = createContainerExecutionId(taskId); - const taskType = executionType || (issueNumber === 0 ? 'analysis' : `issue-${issueNumber}`); const runtimeName = this.getRuntimeName(); - const containerName = this.buildContainerName(this.config.alias || runtimeName, taskType, shortTaskId, modelName); - const dockerArgs: string[] = [ - 'run', '--rm', '-i', '--name', containerName, '--security-opt', 'no-new-privileges', '--cap-add', 'CHOWN', '--network', 'bridge', '--user', '0:0', - '-v', `${worktreePath}:${repositoryInspection ? REPOSITORY_SCOUT_CONTAINER_ROOT : '/home/node/workspace'}:${readOnlyWorkspace ? 'ro' : 'rw'}`, - ...(repositoryInspection ? [] : ['-v', `/tmp/git-processor:/tmp/git-processor:${readOnlyWorkspace ? 'ro' : 'rw'}`]), - '-v', `${configPath}:${this.getContainerConfigPath()}:rw`, - ...(repositoryInspection ? [] : ['-e', `GH_TOKEN=${githubToken}`, '-e', `GITHUB_TOKEN=${githubToken}`]), - '-e', 'ANTIGRAVITY_CLI=1', '-e', 'ANTIGRAVITY_CLI_TRUST_WORKSPACE=true', - ...(readOnlyWorkspace ? ['-e', 'PROPR_REPO_SETUP=0'] : []), - '-e', 'PROPR_EPHEMERAL_STATE=1', '-e', `PROPR_ANTIGRAVITY_SOURCE_CONFIG=${this.getContainerConfigPath()}`, - ...(repositoryInspection ? [ - '-e', 'PROPR_REPOSITORY_INSPECTION=1', - '-e', `PROPR_REPOSITORY_SCOUT_ANTIGRAVITY_MCP_CONFIG=${buildAntigravityRepositoryScoutMcpConfig()}`, - '-e', `PROPR_REPOSITORY_SCOUT_ANTIGRAVITY_PERMISSIONS=${buildAntigravityRepositoryScoutPermissions()}`, - ] : []), - ...(transcriptPath ? ['-e', `PROPR_ANTIGRAVITY_TRANSCRIPT_PATH=${transcriptPath}`] : []), - ...envVars, '-w', '/home/node/workspace', - this.config.dockerImage, '/bin/bash', '-lc', this.buildAntigravityShellCommand(repositoryInspection), 'propr-antigravity' - ]; + const dockerArgs = buildAntigravityDockerArgs({ + worktreePath, githubToken, modelName, issueNumber, environment, + configEnvironment: this.config.envVars, taskId, executionType, transcriptPath, + readOnlyWorkspace, repositoryInspection, executionMode, configPath, + dockerImage: this.config.dockerImage, agentAlias: this.config.alias, + shellCommand: this.buildAntigravityShellCommand(repositoryInspection), + }); // The prompt is delivered through non-TTY stdin, not as an argv element, // to avoid spawn E2BIG on large repo-context prompts. Only CLI flags such // as the model selection are appended here. @@ -471,18 +428,9 @@ export class AntigravityAgent implements Agent { dockerArgs.push('--model', cleanModelName); logger.info({ issueNumber, requestedModel: cleanModelName, originalModel: modelName, agentAlias: this.config.alias }, 'Model specified for Antigravity agent'); } else { logger.debug({ issueNumber, agentAlias: this.config.alias }, 'No model specified, Antigravity agent will use default'); } + if (executionMode === 'goal' && resumeConversationId) dockerArgs.push('--conversation', resumeConversationId); logger.info({ issueNumber, agentAlias: this.config.alias }, 'Docker args built for Antigravity agent'); return wrapDockerRunArgsWithRepoSetup(dockerArgs, this.config.dockerImage, runtimeName); } - private buildContainerName(alias: string, taskType: string, shortTaskId: string, modelName?: string): string { - const suffix = `-${shortTaskId}`; - const rawPrefix = modelName - ? `${alias}-${taskType}-${modelName}` - : `${alias}-${taskType}`; - const maxPrefixLength = Math.max(1, 120 - suffix.length); - const sanitizedPrefix = rawPrefix.replace(/[^a-zA-Z0-9_.-]/g, '-').replace(/^[^a-zA-Z0-9]+/, '').slice(0, maxPrefixLength).replace(/[^a-zA-Z0-9]+$/, ''); - return `${sanitizedPrefix || 'antigravity'}${suffix}`.slice(0, 128); - } - } diff --git a/packages/core/src/agents/impl/ClaudeAgent.ts b/packages/core/src/agents/impl/ClaudeAgent.ts index 070aedfa9..32ca70e12 100644 --- a/packages/core/src/agents/impl/ClaudeAgent.ts +++ b/packages/core/src/agents/impl/ClaudeAgent.ts @@ -78,6 +78,7 @@ export function resolveAnalysisOutcome(claudeOutput: ClaudeOutput, stderr: strin export class ClaudeAgent implements Agent { readonly config: AgentConfig; + readonly goalCapable = true; private readonly maxTurns: number; private readonly timeoutMs: number; @@ -92,7 +93,8 @@ export class ClaudeAgent implements Agent { const { worktreePath, issueRef, prompt: customPrompt, model, systemPrompt, isRetry = false, retryReason, branchName, issueDetails, - onSessionId, onContainerId, githubToken, tools, environment, taskId, prNumber, reasoningLevel, metadata + onSessionId, onContainerId, githubToken, tools, environment, taskId, prNumber, reasoningLevel, + executionMode = 'task', resumeSessionId, metadata } = options; const startTime = Date.now(); @@ -107,25 +109,28 @@ export class ClaudeAgent implements Agent { }, isRetry ? 'Starting Claude agent execution (RETRY)...' : 'Starting Claude agent execution...'); try { - const prompt = buildClaudePrompt({ + const prompt = executionMode === 'goal' ? customPrompt : buildClaudePrompt({ customPrompt, issueRef, branchName, modelName: effectiveModel, issueDetails, isRetry, retryReason }); - await setWorktreeOwnership(worktreePath, issueRef.number); + await setWorktreeOwnership(worktreePath, issueRef.number, { + protectGitMetadata: executionMode === 'goal' && environment?.PROPR_GOAL_LAUNCH_STRATEGY === 'direct', + }); const worktreeGitContent = verifyWorktreeStructure(worktreePath, issueRef.number); effectiveReasoningLevel = await this.resolveEffectiveReasoningLevel(reasoningLevel, effectiveModel); const dockerArgs = buildDockerArgs(this.config, this.maxTurns, { worktreePath, githubToken, modelName: effectiveModel, issueNumber: issueRef.number, systemPrompt, tools, environment, taskId, - reasoningLevel: effectiveReasoningLevel + reasoningLevel: effectiveReasoningLevel, executionMode, resumeSessionId }); const { result, usageMetrics } = await executeWithUsageTracking( 'claude', async () => executeDockerCommand('docker', dockerArgs, { timeout: this.timeoutMs, cwd: worktreePath, onSessionId, onContainerId, - worktreePath, stdinData: prompt, taskId, preserveOutputOnTimeout: true + worktreePath, stdinData: prompt, taskId, + streamToRedis: executionMode === 'goal', preserveOutputOnTimeout: true }) ); diff --git a/packages/core/src/agents/impl/CodexAgent.ts b/packages/core/src/agents/impl/CodexAgent.ts index 95d84e289..360e7060f 100644 --- a/packages/core/src/agents/impl/CodexAgent.ts +++ b/packages/core/src/agents/impl/CodexAgent.ts @@ -17,6 +17,7 @@ import { buildAnalysisSafetySuffix, executeWithUsageTracking } from './utils/ind import type { ExecutionType } from '../../utils/llmMetrics.types.js'; import { resolveAgentTerminationReason } from '../termination.js'; import { buildCodexDockerArgs, type CodexDockerArgsParams } from './utils/codexDockerArgsBuilder.js'; +import { executeCodexAppServerGoal } from './codexAppServer.js'; // Re-export UsageLimitError for convenience export { UsageLimitError }; @@ -30,6 +31,7 @@ type CodexUsageMetrics = Awaited>['u export class CodexAgent implements Agent { readonly config: AgentConfig; + readonly goalCapable = true; private readonly maxTurns: number; private readonly timeoutMs: number; @@ -40,9 +42,11 @@ export class CodexAgent implements Agent { } async executeTask(options: AgentTaskOptions): Promise { + if (options.executionMode === 'goal') return this.executeNativeGoal(options); const { worktreePath, issueRef, prompt: customPrompt, model, systemPrompt, isRetry = false, retryReason, branchName, issueDetails, - onSessionId, onContainerId, githubToken, environment, taskId, prNumber, reasoningLevel, metadata } = options; + onSessionId, onContainerId, githubToken, environment, taskId, prNumber, reasoningLevel, + executionMode = 'task', resumeSessionId, metadata } = options; const startTime = Date.now(); const effectiveModel = model || this.config.defaultModel; @@ -63,7 +67,7 @@ export class CodexAgent implements Agent { const dockerArgs = this.buildDockerArgs({ worktreePath, githubToken, modelName: effectiveModel, issueNumber: issueRef.number, environment, taskId, - reasoningLevel: effectiveReasoningLevel + reasoningLevel: effectiveReasoningLevel, executionMode, resumeSessionId }); const { result, usageMetrics } = await executeWithUsageTracking( @@ -102,6 +106,18 @@ export class CodexAgent implements Agent { } } + private async executeNativeGoal(options: AgentTaskOptions): Promise { + await setWorktreeOwnership(options.worktreePath, options.issueRef.number, { + protectGitMetadata: options.environment?.PROPR_GOAL_LAUNCH_STRATEGY === 'direct', + }); + const worktreeGitContent = verifyWorktreeStructure(options.worktreePath, options.issueRef.number); + const result = await executeCodexAppServerGoal(this.config, options, this.timeoutMs); + if (result.success) { + verifyWorktreePostExecution(options.worktreePath, options.issueRef.number, worktreeGitContent); + } + return result; + } + private buildTaskExecutionResult(params: { parsedOutput: CodexParsedOutput; result: CodexExecutionOutput; diff --git a/packages/core/src/agents/impl/OpenCodeAgent.ts b/packages/core/src/agents/impl/OpenCodeAgent.ts index bf1e49cd0..8898106fe 100644 --- a/packages/core/src/agents/impl/OpenCodeAgent.ts +++ b/packages/core/src/agents/impl/OpenCodeAgent.ts @@ -43,6 +43,7 @@ function buildFailedExecutionResult(error: Error & { stderr?: string }, executio export class OpenCodeAgent implements Agent { readonly config: AgentConfig; + readonly goalCapable = false; private readonly timeoutMs: number; constructor(config: AgentConfig) { diff --git a/packages/core/src/agents/impl/VibeAgent.ts b/packages/core/src/agents/impl/VibeAgent.ts index 7c6f8301b..fd1a43004 100644 --- a/packages/core/src/agents/impl/VibeAgent.ts +++ b/packages/core/src/agents/impl/VibeAgent.ts @@ -48,6 +48,7 @@ interface VibeDockerArgsParams { export class VibeAgent implements Agent { readonly config: AgentConfig; + readonly goalCapable = false; private readonly maxTurns: number; private readonly timeoutMs: number; diff --git a/packages/core/src/agents/impl/codexAppServer.ts b/packages/core/src/agents/impl/codexAppServer.ts new file mode 100644 index 000000000..2ae9c5121 --- /dev/null +++ b/packages/core/src/agents/impl/codexAppServer.ts @@ -0,0 +1,432 @@ +import { spawn, execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { + getDockerRunContainerName, + getExecutionAbortError, + getExecutionOwnershipContext, + resolveExecutionArgs, +} from '../../claude/docker/dockerExecutionOwnership.js'; +import type { + AgentConfig, + AgentExecutionResult, + AgentTaskOptions, + GoalCheckpointRequest, +} from '../types.js'; +import { AppServerConnection, asRecord, type RpcMessage } from './codexAppServerConnection.js'; +import { buildCodexAppServerDockerArgs } from './utils/codexDockerArgsBuilder.js'; + +const execFileAsync = promisify(execFile); +export const CODEX_APP_SERVER_INITIALIZE_TIMEOUT_MS = 5 * 60 * 1000; + +interface ThreadIdentity { + id: string; + sessionId: string; + model?: string; +} + +interface GoalProtocolResult { + thread: ThreadIdentity; + completion?: { status: string; error?: string }; + effectiveModel?: string; +} + +interface NativeGoalSnapshot { + status: string; + objective?: string; +} + +function nativeGoalObjective(options: AgentTaskOptions): string { + return options.nativeGoalObjective!; +} + +function cleanModelName(model: string | undefined): string | undefined { + return model?.includes(':') ? model.split(':').pop() : model; +} + +function extractThread(result: Record, fallbackSessionId?: string): ThreadIdentity { + const thread = asRecord(result.thread); + if (typeof thread.id !== 'string') throw new Error('Codex App Server did not return thread.id'); + const sessionId = typeof thread.sessionId === 'string' ? thread.sessionId : fallbackSessionId; + if (!sessionId) throw new Error('Codex App Server did not return thread.sessionId'); + return { + id: thread.id, + sessionId, + ...(typeof thread.model === 'string' + ? { model: thread.model } + : typeof result.model === 'string' ? { model: result.model } : {}), + }; +} + +interface TurnCompletion { + status: string; + error?: string; + checkpoint?: GoalCheckpointRequest; +} + +function turnStatus(message: RpcMessage): TurnCompletion { + if (message.error) return { status: 'failed', error: message.error.message }; + const turn = asRecord(message.params?.turn); + const error = asRecord(turn.error); + return { + status: typeof turn.status === 'string' ? turn.status : 'failed', + ...(typeof error.message === 'string' ? { error: error.message } : {}), + }; +} + +async function detectContainer( + containerName: string | null, + callback: AgentTaskOptions['onContainerId'], +): Promise { + if (!containerName || !callback) return; + for (let attempt = 0; attempt < 10; attempt += 1) { + try { + const { stdout } = await execFileAsync('docker', ['inspect', '--format', '{{.Id}}', containerName]); + const id = stdout.trim(); + if (id) return void await callback(id, containerName); + } catch { /* container creation may still be in progress */ } + await new Promise(resolve => setTimeout(resolve, 200)); + } +} + +async function openGoalThread( + connection: AppServerConnection, + options: AgentTaskOptions, + model: string | undefined, +): Promise { + // The request is buffered while the repository setup hook and container + // entrypoint run. Large repositories can legitimately take longer than the + // ordinary RPC timeout before App Server begins consuming stdin. + await connection.request( + 'initialize', + { clientInfo: { name: 'propr', title: 'ProPR', version: '1' } }, + CODEX_APP_SERVER_INITIALIZE_TIMEOUT_MS, + ); + connection.notify('initialized'); + const result = options.resumeSessionId + ? await connection.request('thread/resume', { threadId: options.resumeSessionId, ...(model ? { model } : {}) }) + : await connection.request('thread/start', { + ...(model ? { model } : {}), cwd: '/home/node/workspace', approvalPolicy: 'never', + sandbox: 'danger-full-access', serviceName: 'propr', + }); + const thread = extractThread(result, options.resumeConversationId); + if (options.resumeSessionId && thread.id !== options.resumeSessionId) { + throw new Error('Codex App Server resumed a different thread than the persisted goal identity'); + } + connection.effectiveModel = thread.model; + if (!options.resumeSessionId) { + // Materialize the immutable objective without starting work, then make + // the exact thread identity durable. A crash can now reopen a real goal + // instead of publishing an empty thread that cannot be recovered. + await connection.request('thread/goal/set', { + threadId: thread.id, + objective: nativeGoalObjective(options), + status: 'paused', + }); + } + await options.onSessionId?.(thread.id, thread.sessionId); + return thread; +} + +function nativeGoalSnapshot(result: Record): NativeGoalSnapshot { + const goal = asRecord(result.goal); + if (typeof goal.status !== 'string') throw new Error('Codex App Server did not return the native goal status'); + return { + status: goal.status, + ...(typeof goal.objective === 'string' ? { objective: goal.objective } : {}), + }; +} + +async function waitForNativeGoalTurn( + connection: AppServerConnection, + threadId: string, + control: NonNullable, + objective: string, +): Promise { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const turnId = connection.takeStartedTurn(threadId); + if (turnId) return turnId; + if (connection.closeError) throw connection.closeError; + await control.heartbeat(); + const desiredState = (await control.load()).desiredState; + if (desiredState !== 'running') { + await applyNativeGoalStop(connection, { threadId, desiredState, objective }); + return null; + } + await new Promise(resolve => setTimeout(resolve, 400)); + } + throw new Error('Codex App Server goal remained active without starting its next native turn'); +} + +async function applyNativeGoalStop( + connection: AppServerConnection, + options: { + threadId: string; + desiredState: 'paused' | 'cancelled'; + objective: string; + turnId?: string; + }, +): Promise { + const { threadId, desiredState, objective, turnId } = options; + if (desiredState === 'cancelled') { + await connection.request('thread/goal/clear', { threadId }); + } else { + await connection.request('thread/goal/set', { threadId, objective, status: 'paused' }); + } + if (turnId) await connection.request('turn/interrupt', { threadId, turnId }).catch(() => undefined); +} + +async function observeNativeGoal( + connection: AppServerConnection, + threadId: string, + initialTurnId: string, + options: AgentTaskOptions, +): Promise<{ status: string; error?: string }> { + const control = options.goalControl!; + const objective = nativeGoalObjective(options); + let turnId = initialTurnId; + let firstTurn = true; + while (true) { + connection.discardStartedTurn(turnId); + await control.setActiveTurn(turnId); + if (firstTurn && options.initialControlInputId) { + await connection.request('turn/steer', { + threadId, + clientUserMessageId: options.initialControlInputId, + input: [{ type: 'text', text: options.prompt, text_elements: [] }], + expectedTurnId: turnId, + }); + await control.markInputDelivered(options.initialControlInputId, turnId); + } + firstTurn = false; + const completion = await observeActiveTurnWithThread(connection, threadId, turnId, options); + await control.setActiveTurn(null); + if (completion.status !== 'completed') return completion; + let boundary = await control.load(); + const checkpoint = completion.checkpoint ?? boundary.checkpoint; + let completedDuringCheckpoint = false; + if (checkpoint) { + if (!completion.checkpoint) { + await connection.request('thread/goal/set', { + threadId, objective, status: 'paused', + }); + } + await control.publishCheckpoint(checkpoint, turnId); + boundary = await control.load(); + const nativeBoundary = nativeGoalSnapshot(await connection.request('thread/goal/get', { threadId })); + completedDuringCheckpoint = nativeBoundary.status === 'complete'; + if (boundary.desiredState === 'running' && nativeBoundary.status === 'paused') { + await connection.request('thread/goal/set', { + threadId, objective, status: 'active', + }); + } + } + const desiredState = boundary.desiredState; + if (desiredState !== 'running') { + await applyNativeGoalStop(connection, { threadId, desiredState, objective }); + return { status: 'interrupted', error: 'Goal stopped at a provider turn boundary' }; + } + if (completedDuringCheckpoint) return completion; + const goal = nativeGoalSnapshot(await connection.request('thread/goal/get', { threadId })); + if (goal.status === 'complete') return completion; + if (goal.status !== 'active') { + return { status: 'failed', error: `Codex native goal entered ${goal.status} status` }; + } + const nextTurnId = await waitForNativeGoalTurn(connection, threadId, control, objective); + if (!nextTurnId) return { status: 'interrupted', error: 'Goal stopped between provider turns' }; + turnId = nextTurnId; + } +} + +export async function runGoalProtocol( + connection: AppServerConnection, + options: AgentTaskOptions, + model: string | undefined, +): Promise { + const control = options.goalControl!; + const thread = await openGoalThread(connection, options, model); + if (options.resumeSessionId) { + const recoveredGoal = nativeGoalSnapshot(await connection.request('thread/goal/get', { threadId: thread.id })); + if (recoveredGoal.objective !== nativeGoalObjective(options)) { + throw new Error('Persisted Codex thread belongs to a different native goal objective'); + } + if (recoveredGoal.status === 'complete') { + if (options.initialControlInputId) { + await control.markInputUndeliverable( + options.initialControlInputId, + 'Codex native goal completed before this FIFO input could be delivered', + ); + } + return { thread, completion: { status: 'completed' }, effectiveModel: thread.model }; + } + if (['paused', 'blocked', 'usageLimited'].includes(recoveredGoal.status)) { + await connection.request('thread/goal/set', { + threadId: thread.id, + objective: nativeGoalObjective(options), + status: 'active', + }); + } else if (recoveredGoal.status !== 'active') { + return { + thread, + completion: { status: 'failed', error: `Codex native goal resumed in ${recoveredGoal.status} status` }, + effectiveModel: thread.model, + }; + } + } + const boundary = await control.load(); + if (boundary.desiredState !== 'running') { + const startedTurnId = connection.takeStartedTurn(thread.id) ?? undefined; + await applyNativeGoalStop(connection, { + threadId: thread.id, + desiredState: boundary.desiredState, + objective: nativeGoalObjective(options), + ...(startedTurnId ? { turnId: startedTurnId } : {}), + }); + return { + thread, + completion: { status: 'interrupted', error: 'Goal stopped before provider turn observation' }, + effectiveModel: thread.model, + }; + } + if (!options.resumeSessionId) { + // App Server 0.146 activates the external goal and continues the thread + // itself. Activate once only after identity persistence and the final + // desired-state check above. + await connection.request('thread/goal/set', { + threadId: thread.id, + objective: nativeGoalObjective(options), + status: 'active', + }); + } + const effectiveModel = thread.model; + // Applying/resuming an active external goal calls continue_if_idle() in the + // pinned App Server. Starting another turn here races that native turn. + const turnId = await waitForNativeGoalTurn(connection, thread.id, control, nativeGoalObjective(options)); + if (!turnId) return { thread, effectiveModel }; + const completion = await observeNativeGoal(connection, thread.id, turnId, options); + await connection.request('thread/goal/get', { threadId: thread.id }).catch(() => undefined); + return { thread, completion, effectiveModel }; +} + +function protocolResult( + connection: AppServerConnection, + protocol: GoalProtocolResult, + start: number, +): AgentExecutionResult { + const { thread, completion, effectiveModel } = protocol; + const success = completion?.status === 'completed'; + return { + success, + logs: `${connection.rawOutput}${connection.stderrOutput ? `\n${connection.stderrOutput}` : ''}`, + rawOutput: connection.rawOutput, + conversationLog: connection.conversationLog, + summary: connection.summaryParts.join('\n\n') || undefined, + modifiedFiles: [], + modelUsed: connection.effectiveModel || effectiveModel || 'unknown', + providerModel: connection.effectiveModel || effectiveModel, + sessionId: thread.id, + conversationId: thread.sessionId, + executionTimeMs: Date.now() - start, + tokenUsage: connection.tokenUsage, + exitCode: 0, + ...(!success ? { error: completion?.error || (completion ? `Codex turn ${completion.status}` : 'Goal paused before turn start') } : {}), + }; +} + +export async function executeCodexAppServerGoal( + config: AgentConfig, + options: AgentTaskOptions, + timeoutMs: number, +): Promise { + const start = Date.now(); + const model = cleanModelName(options.model || config.defaultModel); + const control = options.goalControl; + if (!control || !options.nativeGoalObjective) throw new Error('Codex native goal execution requires durable goal controls and an objective'); + const dockerArgs = buildCodexAppServerDockerArgs(config, { + worktreePath: options.worktreePath, + githubToken: options.githubToken, + issueNumber: options.issueRef.number, + environment: options.environment, + taskId: options.taskId, + }); + const ownership = getExecutionOwnershipContext(); + const args = resolveExecutionArgs('docker', dockerArgs, options.taskId, ownership?.attemptGeneration); + const child = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: options.worktreePath }); + const abort = (): void => { child.kill('SIGTERM'); }; + ownership?.signal.addEventListener('abort', abort, { once: true }); + const connection = new AppServerConnection(child, options.taskId, records => control.appendOutput(records)); + void detectContainer(getDockerRunContainerName(args), options.onContainerId); + const deadline = setTimeout(() => child.kill('SIGTERM'), timeoutMs); + let thread: ThreadIdentity | undefined; + try { + const protocol = await runGoalProtocol(connection, options, model); + thread = protocol.thread; + return protocolResult(connection, protocol, start); + } catch (error) { + const abortError = getExecutionAbortError(ownership?.signal); + const message = (abortError ?? error as Error).message; + return { + success: false, + logs: `${connection.rawOutput}${connection.stderrOutput ? `\n${connection.stderrOutput}` : ''}`, + rawOutput: connection.rawOutput, + conversationLog: connection.conversationLog, + modifiedFiles: [], modelUsed: connection.effectiveModel || thread?.model || 'unknown', + providerModel: connection.effectiveModel || thread?.model, + sessionId: thread?.id, conversationId: thread?.sessionId, + executionTimeMs: Date.now() - start, error: message, exitCode: child.exitCode, + }; + } finally { + clearTimeout(deadline); + ownership?.signal.removeEventListener('abort', abort); + await control.setActiveTurn(null).catch(() => undefined); + await connection.close(); + } +} + +async function observeActiveTurnWithThread( + connection: AppServerConnection, + threadId: string, + turnId: string, + options: AgentTaskOptions, +): Promise { + const control = options.goalControl!; + const objective = nativeGoalObjective(options); + let completed: RpcMessage | null = null; + const completion = connection.waitForTurn(turnId).then(message => { completed = message; }); + let interrupted = false; + let checkpoint: GoalCheckpointRequest | undefined; + while (!completed) { + await Promise.race([completion, new Promise(resolve => setTimeout(resolve, 400))]); + if (completed) break; + await control.heartbeat(); + const snapshot = await control.load(); + if (snapshot.desiredState !== 'running') { + if (!interrupted) { + interrupted = true; + await applyNativeGoalStop(connection, { + threadId, desiredState: snapshot.desiredState, objective, turnId, + }); + } + continue; + } + if (!checkpoint && snapshot.checkpoint) { + checkpoint = snapshot.checkpoint; + // Pausing the external goal does not interrupt the current turn. It + // prevents native auto-continuation from racing the worker's git + // checkpoint after this turn reaches its safe boundary. + await connection.request('thread/goal/set', { + threadId, objective, status: 'paused', + }); + } + for (const input of snapshot.pendingInputs) { + await connection.request('turn/steer', { + threadId, + clientUserMessageId: input.id, + input: [{ type: 'text', text: input.message, text_elements: [] }], + expectedTurnId: turnId, + }); + await control.markInputDelivered(input.id, turnId); + } + } + return { ...turnStatus(completed!), ...(checkpoint ? { checkpoint } : {}) }; +} diff --git a/packages/core/src/agents/impl/codexAppServerConnection.ts b/packages/core/src/agents/impl/codexAppServerConnection.ts new file mode 100644 index 000000000..bc9922a96 --- /dev/null +++ b/packages/core/src/agents/impl/codexAppServerConnection.ts @@ -0,0 +1,264 @@ +import { spawn } from 'node:child_process'; +import readline from 'node:readline'; +import { Redis } from 'ioredis'; +import logger from '../../utils/logger.js'; +import type { AgentExecutionResult, TokenUsage } from '../types.js'; +import { + boundedProviderDiagnostic, + boundedProviderOutput, + MAX_PROVIDER_OUTPUT_BYTES, +} from './utils/boundedProviderOutput.js'; + +const MAX_LIVE_OUTPUT_BYTES = MAX_PROVIDER_OUTPUT_BYTES; +const MAX_SUMMARY_PARTS = 100; +const APPEND_BOUNDED_OUTPUT_SCRIPT = ` +local combined = (redis.call('get', KEYS[1]) or '') .. ARGV[1] +local maximum = tonumber(ARGV[2]) +if string.len(combined) > maximum then + combined = string.sub(combined, string.len(combined) - maximum + 1) + local boundary = string.find(combined, '\\n') + if boundary then combined = string.sub(combined, boundary + 1) end +end +redis.call('setex', KEYS[1], tonumber(ARGV[3]), combined) +return string.len(combined) +`; + +export function boundedCodexJsonlTail(value: string, maximum = MAX_LIVE_OUTPUT_BYTES): string { + return boundedProviderOutput(value, maximum); +} + +interface RpcError { code?: number; message?: string } +export interface RpcMessage { + id?: number; + method?: string; + result?: Record; + error?: RpcError; + params?: Record; +} + +interface PendingRequest { + method: string; + resolve(value: Record): void; + reject(error: Error): void; + timer: ReturnType; +} + +export function asRecord(value: unknown): Record { + return value && typeof value === 'object' ? value as Record : {}; +} + +function extractTokenUsage(params: Record): TokenUsage | undefined { + const usage = asRecord(params.tokenUsage ?? params.usage ?? params.total); + const total = asRecord(usage.total ?? usage); + const input = Number(total.inputTokens ?? total.input_tokens ?? 0); + const output = Number(total.outputTokens ?? total.output_tokens ?? 0); + const cached = Number(total.cachedInputTokens ?? total.cache_read_input_tokens ?? 0); + if (!input && !output && !cached) return undefined; + return { input_tokens: input, output_tokens: output, cache_read_input_tokens: cached }; +} + +export class AppServerConnection { + private nextId = 1; + private pending = new Map(); + private turnWaiters = new Map void>(); + private completedTurns = new Map(); + private startedTurns: Array<{ threadId: string; turnId: string }> = []; + private redis: Redis; + private output = ''; + private stderr = ''; + private flushTimer: ReturnType | null = null; + private pendingOutput = ''; + private flushPromise: Promise = Promise.resolve(); + private closedError: Error | null = null; + summaryParts: string[] = []; + tokenUsage?: TokenUsage; + effectiveModel?: string; + + constructor( + private child: ReturnType, + private taskId: string | undefined, + private persistOutput?: (records: string[]) => Promise, + ) { + this.redis = new Redis({ + host: process.env.REDIS_HOST || 'redis', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + maxRetriesPerRequest: 1, + }); + child.stderr?.on('data', chunk => { + this.stderr = boundedProviderDiagnostic(this.stderr + chunk.toString()); + }); + const lines = readline.createInterface({ input: child.stdout! }); + lines.on('line', line => this.onLine(line)); + child.once('close', code => this.closePending(new Error(`Codex App Server exited before the active turn completed (exit ${code ?? 'unknown'})`))); + child.once('error', error => this.closePending(error)); + } + + get rawOutput(): string { return this.output; } + get conversationLog(): AgentExecutionResult['conversationLog'] { + return this.output.split('\n').flatMap(line => { + try { return [JSON.parse(line) as Record]; } catch { return []; } + }) as AgentExecutionResult['conversationLog']; + } + get stderrOutput(): string { return this.stderr; } + get closeError(): Error | null { return this.closedError; } + + private onLine(line: string): void { + this.appendOutput(`${line}\n`); + this.scheduleFlush(); + let message: RpcMessage; + try { message = JSON.parse(line) as RpcMessage; } catch { return; } + if (typeof message.id === 'number' && this.pending.has(message.id)) { + const pending = this.pending.get(message.id)!; + clearTimeout(pending.timer); + this.pending.delete(message.id); + if (message.error) pending.reject(new Error(`Codex ${message.error.message || 'request failed'} (${message.error.code ?? 'unknown'})`)); + else { + this.appendGoalSnapshot(pending.method, message.result ?? {}); + pending.resolve(message.result ?? {}); + } + return; + } + this.observeNotification(message); + } + + private observeNotification(message: RpcMessage): void { + const params = message.params ?? {}; + if (message.method === 'turn/started') { + const turn = asRecord(params.turn); + if (typeof params.threadId === 'string' && typeof turn.id === 'string') { + this.startedTurns.push({ threadId: params.threadId, turnId: turn.id }); + } + } + if (message.method === 'turn/completed') { + const turn = asRecord(params.turn); + if (typeof turn.model === 'string') this.effectiveModel = turn.model; + if (typeof turn.id === 'string') { + const waiter = this.turnWaiters.get(turn.id); + if (waiter) { + this.turnWaiters.delete(turn.id); + waiter(message); + } else { + this.completedTurns.set(turn.id, message); + } + } + } + if (message.method === 'item/completed') { + const item = asRecord(params.item); + if (typeof item.model === 'string') this.effectiveModel = item.model; + if (item.type === 'agentMessage' && typeof item.text === 'string') { + this.summaryParts.push(item.text); + if (this.summaryParts.length > MAX_SUMMARY_PARTS) this.summaryParts.shift(); + } + } + if (message.method === 'thread/tokenUsage/updated') this.tokenUsage = extractTokenUsage(params) ?? this.tokenUsage; + if (message.method === 'model/rerouted' && typeof params.toModel === 'string') this.effectiveModel = params.toModel; + } + + private appendGoalSnapshot(method: string, result: Record): void { + if (!['thread/goal/set', 'thread/goal/get'].includes(method) || !result.goal) return; + const line = JSON.stringify({ method: 'thread/goal/updated', params: { goal: result.goal }, source: 'rpc_snapshot' }); + this.appendOutput(`${line}\n`); + this.scheduleFlush(); + } + + private boundedTail(value: string): string { + return boundedCodexJsonlTail(value); + } + + private appendOutput(value: string): void { + this.output = this.boundedTail(this.output + value); + this.pendingOutput = this.boundedTail(this.pendingOutput + value); + } + + private flushOutput(): Promise { + if (!this.taskId || !this.pendingOutput) return this.flushPromise; + const chunk = this.pendingOutput; + this.pendingOutput = ''; + this.flushPromise = this.flushPromise.then(async () => { + await Promise.all([ + this.redis.eval( + APPEND_BOUNDED_OUTPUT_SCRIPT, + 1, + `agent:output:${this.taskId}`, + chunk, + String(MAX_LIVE_OUTPUT_BYTES), + '3600', + ), + this.persistOutput?.(chunk.split('\n').filter(Boolean)), + ]); + }).catch(error => { + logger.debug({ error: (error as Error).message }, 'Failed to persist Codex App Server output'); + }); + return this.flushPromise; + } + + private scheduleFlush(): void { + if (!this.taskId || this.flushTimer) return; + this.flushTimer = setTimeout(() => { + this.flushTimer = null; + void this.flushOutput(); + }, 200); + } + + private closePending(error: Error): void { + this.closedError = error; + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + for (const resolve of this.turnWaiters.values()) resolve({ error: { message: error.message } }); + this.turnWaiters.clear(); + } + + notify(method: string, params: Record = {}): void { + if (!this.child.stdin?.writable) throw this.closedError ?? new Error('Codex App Server stdin is closed'); + this.child.stdin.write(`${JSON.stringify({ method, params })}\n`); + } + + request(method: string, params: Record, timeoutMs = 30_000): Promise> { + if (!this.child.stdin?.writable) return Promise.reject(this.closedError ?? new Error('Codex App Server stdin is closed')); + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Codex App Server ${method} timed out`)); + }, timeoutMs); + this.pending.set(id, { method, resolve, reject, timer }); + this.child.stdin!.write(`${JSON.stringify({ method, id, params })}\n`); + }); + } + + waitForTurn(turnId: string): Promise { + if (this.closedError) return Promise.reject(this.closedError); + const completed = this.completedTurns.get(turnId); + if (completed) { + this.completedTurns.delete(turnId); + return Promise.resolve(completed); + } + return new Promise(resolve => this.turnWaiters.set(turnId, resolve)); + } + + takeStartedTurn(threadId: string): string | null { + const index = this.startedTurns.findIndex(turn => turn.threadId === threadId); + if (index < 0) return null; + return this.startedTurns.splice(index, 1)[0].turnId; + } + + discardStartedTurn(turnId: string): void { + this.startedTurns = this.startedTurns.filter(turn => turn.turnId !== turnId); + } + + async close(): Promise { + if (this.flushTimer) clearTimeout(this.flushTimer); + await this.flushOutput(); + await this.redis.quit().catch(() => undefined); + this.child.stdin?.end(); + const force = setTimeout(() => this.child.kill('SIGTERM'), 500); + await new Promise(resolve => { + if (this.child.exitCode !== null) resolve(); + else this.child.once('close', () => resolve()); + }); + clearTimeout(force); + } +} diff --git a/packages/core/src/agents/impl/utils/antigravityContainerName.ts b/packages/core/src/agents/impl/utils/antigravityContainerName.ts new file mode 100644 index 000000000..d66e246c0 --- /dev/null +++ b/packages/core/src/agents/impl/utils/antigravityContainerName.ts @@ -0,0 +1,18 @@ +export function buildAntigravityContainerName( + alias: string, + taskType: string, + shortTaskId: string, + modelName?: string, +): string { + const suffix = `-${shortTaskId}`; + const rawPrefix = modelName + ? `${alias}-${taskType}-${modelName}` + : `${alias}-${taskType}`; + const maxPrefixLength = Math.max(1, 120 - suffix.length); + const sanitizedPrefix = rawPrefix + .replace(/[^a-zA-Z0-9_.-]/g, '-') + .replace(/^[^a-zA-Z0-9]+/, '') + .slice(0, maxPrefixLength) + .replace(/[^a-zA-Z0-9]+$/, ''); + return `${sanitizedPrefix || 'antigravity'}${suffix}`.slice(0, 128); +} diff --git a/packages/core/src/agents/impl/utils/antigravityDockerArgsBuilder.ts b/packages/core/src/agents/impl/utils/antigravityDockerArgsBuilder.ts new file mode 100644 index 000000000..7765af006 --- /dev/null +++ b/packages/core/src/agents/impl/utils/antigravityDockerArgsBuilder.ts @@ -0,0 +1,120 @@ +import path from 'path'; +import { createContainerExecutionId } from './containerExecutionId.js'; +import { buildAntigravityContainerName } from './antigravityContainerName.js'; +import { + buildAntigravityRepositoryScoutMcpConfig, + buildAntigravityRepositoryScoutPermissions, + REPOSITORY_SCOUT_CONTAINER_ROOT, +} from './repositoryScoutMcpServer.js'; + +const ANTIGRAVITY_CONTAINER_SOURCE_CONFIG_PATH = '/home/node/.gemini-source'; +const GITHUB_CREDENTIAL_ENV_PATTERN = /^(?:GH|GITHUB)_.*(?:TOKEN|KEY|SECRET|PASSWORD|PAT|PRIVATE_KEY)$/; + +interface AntigravityDockerArgsParams { + worktreePath: string; + githubToken: string; + modelName?: string; + issueNumber: number; + environment?: Record; + configEnvironment?: Record; + taskId?: string; + executionType?: string; + transcriptPath?: string; + readOnlyWorkspace?: boolean; + repositoryInspection?: boolean; + executionMode?: 'task' | 'goal'; + configPath: string; + dockerImage: string; + agentAlias?: string; + shellCommand: string; +} + +function buildAgentEnvironmentArgs( + omitGithubCredentials: boolean, + ...sources: Array | undefined> +): string[] { + const args: string[] = []; + for (const source of sources) { + if (!source) continue; + for (const [key, value] of Object.entries(source)) { + if (omitGithubCredentials && GITHUB_CREDENTIAL_ENV_PATTERN.test(key.toUpperCase())) continue; + args.push('-e', `${key}=${value}`); + } + } + return args; +} + +function buildWorkspaceMountArgs( + params: Pick, + workerOwnedGoalGit: boolean, +): string[] { + const { worktreePath, readOnlyWorkspace, repositoryInspection } = params; + return [ + '-v', `${worktreePath}:${repositoryInspection ? REPOSITORY_SCOUT_CONTAINER_ROOT : '/home/node/workspace'}:${readOnlyWorkspace ? 'ro' : 'rw'}`, + ...(workerOwnedGoalGit ? ['-v', `${path.join(worktreePath, '.git')}:/home/node/workspace/.git:ro`] : []), + ...(repositoryInspection ? [] : [ + '-v', `/tmp/git-processor:/tmp/git-processor:${readOnlyWorkspace || workerOwnedGoalGit ? 'ro' : 'rw'}`, + ]), + ]; +} + +function buildCredentialArgs(repositoryInspection: boolean, workerOwnedGoalGit: boolean, githubToken: string): string[] { + return repositoryInspection || workerOwnedGoalGit + ? [] + : ['-e', `GH_TOKEN=${githubToken}`, '-e', `GITHUB_TOKEN=${githubToken}`]; +} + +function buildRepositoryInspectionArgs(repositoryInspection: boolean): string[] { + return repositoryInspection ? [ + '-e', 'PROPR_REPOSITORY_INSPECTION=1', + '-e', `PROPR_REPOSITORY_SCOUT_ANTIGRAVITY_MCP_CONFIG=${buildAntigravityRepositoryScoutMcpConfig()}`, + '-e', `PROPR_REPOSITORY_SCOUT_ANTIGRAVITY_PERMISSIONS=${buildAntigravityRepositoryScoutPermissions()}`, + ] : []; +} + +export function buildAntigravityDockerArgs(params: AntigravityDockerArgsParams): string[] { + const { + worktreePath, githubToken, modelName, issueNumber, environment, configEnvironment, + taskId, executionType, transcriptPath, readOnlyWorkspace = false, + repositoryInspection = false, executionMode = 'task', configPath, dockerImage, + agentAlias, shellCommand, + } = params; + if (repositoryInspection && !readOnlyWorkspace) { + throw new Error('Repository inspection requires a read-only workspace'); + } + const configMountTarget = executionMode === 'goal' + ? '/home/node/.gemini' + : ANTIGRAVITY_CONTAINER_SOURCE_CONFIG_PATH; + const workerOwnedGoalGit = executionMode === 'goal' + && environment?.PROPR_GOAL_LAUNCH_STRATEGY === 'direct'; + const envVars = buildAgentEnvironmentArgs( + repositoryInspection || workerOwnedGoalGit, + configEnvironment, + environment, + ); + const taskType = executionMode === 'goal' + ? 'goal' + : executionType || (issueNumber === 0 ? 'analysis' : `issue-${issueNumber}`); + const containerName = buildAntigravityContainerName( + agentAlias || 'antigravity', + taskType, + createContainerExecutionId(taskId), + modelName, + ); + + return [ + 'run', '--rm', '-i', '--name', containerName, '--security-opt', 'no-new-privileges', + '--cap-add', 'CHOWN', '--network', 'bridge', '--user', '0:0', + ...buildWorkspaceMountArgs({ worktreePath, readOnlyWorkspace, repositoryInspection }, workerOwnedGoalGit), + '-v', `${configPath}:${configMountTarget}:rw`, + ...buildCredentialArgs(repositoryInspection, workerOwnedGoalGit, githubToken), + '-e', 'ANTIGRAVITY_CLI=1', '-e', 'ANTIGRAVITY_CLI_TRUST_WORKSPACE=true', + ...(readOnlyWorkspace ? ['-e', 'PROPR_REPO_SETUP=0'] : []), + ...(executionMode === 'task' ? ['-e', 'PROPR_EPHEMERAL_STATE=1'] : []), + '-e', `PROPR_ANTIGRAVITY_SOURCE_CONFIG=${configMountTarget}`, + ...buildRepositoryInspectionArgs(repositoryInspection), + ...(transcriptPath ? ['-e', `PROPR_ANTIGRAVITY_TRANSCRIPT_PATH=${transcriptPath}`] : []), + ...envVars, '-w', '/home/node/workspace', + dockerImage, '/bin/bash', '-lc', shellCommand, 'propr-antigravity', + ]; +} diff --git a/packages/core/src/agents/impl/utils/boundedProviderOutput.ts b/packages/core/src/agents/impl/utils/boundedProviderOutput.ts new file mode 100644 index 000000000..f409130a8 --- /dev/null +++ b/packages/core/src/agents/impl/utils/boundedProviderOutput.ts @@ -0,0 +1,111 @@ +export const MAX_PROVIDER_OUTPUT_BYTES = 1024 * 1024; + +export class BoundedProviderRecordBuffer { + private pinned = ''; + private complete = ''; + private partial = ''; + private droppingOversizedRecord = false; + private sawFirstRecord = false; + + constructor(private readonly maximumBytes = MAX_PROVIDER_OUTPUT_BYTES) {} + + append(chunk: string): string { + let remaining = chunk; + while (remaining) { + const boundary = remaining.indexOf('\n'); + if (this.droppingOversizedRecord) { + if (boundary < 0) return this.output; + this.droppingOversizedRecord = false; + remaining = remaining.slice(boundary + 1); + continue; + } + if (boundary < 0) { + const partial = this.partial + remaining; + if (Buffer.byteLength(partial) > this.maximumBytes) { + this.partial = ''; + this.droppingOversizedRecord = true; + } else { + this.partial = partial; + this.trimComplete(); + } + return this.output; + } + const record = `${this.partial}${remaining.slice(0, boundary + 1)}`; + this.partial = ''; + if (Buffer.byteLength(record) <= this.maximumBytes) { + if (!this.sawFirstRecord) this.pinned = record; + else this.complete += record; + this.trimComplete(); + } + this.sawFirstRecord = true; + remaining = remaining.slice(boundary + 1); + } + return this.output; + } + + get output(): string { + return this.pinned + this.complete + this.partial; + } + + private trimComplete(): void { + while (Buffer.byteLength(this.output) > this.maximumBytes) { + const boundary = this.complete.indexOf('\n'); + if (boundary < 0) { + this.complete = ''; + if (Buffer.byteLength(this.output) > this.maximumBytes) this.partial = ''; + return; + } + this.complete = this.complete.slice(boundary + 1); + } + } +} + +/** Keep the newest complete provider records without splitting UTF-8 characters. */ +export function boundedProviderOutput( + value: string, + maximumBytes = MAX_PROVIDER_OUTPUT_BYTES, +): string { + if (maximumBytes <= 0 || !value) return ''; + const encoded = Buffer.from(value); + if (encoded.byteLength <= maximumBytes) return value; + + // Provider streams are JSONL (or line-oriented plain text). Starting after + // the first newline rejects an individual oversized record and guarantees + // that retained JSONL never begins in the middle of a record or code point. + const tail = encoded.subarray(encoded.byteLength - maximumBytes).toString('utf8'); + const boundary = tail.indexOf('\n'); + return boundary < 0 ? '' : tail.slice(boundary + 1); +} + +/** Keep a byte-bounded diagnostic tail when record boundaries are irrelevant. */ +export function boundedProviderDiagnostic( + value: string, + maximumBytes = MAX_PROVIDER_OUTPUT_BYTES, +): string { + if (maximumBytes <= 0 || !value) return ''; + const encoded = Buffer.from(value); + if (encoded.byteLength <= maximumBytes) return value; + let tail = encoded.subarray(encoded.byteLength - maximumBytes).toString('utf8'); + while (Buffer.byteLength(tail) > maximumBytes || tail.startsWith('\uFFFD')) tail = tail.slice(1); + return tail; +} + +/** Read at most one bounded tail from a provider transcript on disk. */ +export async function readBoundedProviderOutputFile(filePath: string): Promise { + const handle = await fs.promises.open(filePath, 'r'); + try { + const stats = await handle.stat(); + const start = Math.max(0, stats.size - MAX_PROVIDER_OUTPUT_BYTES); + const buffer = Buffer.alloc(stats.size - start); + await handle.read(buffer, 0, buffer.length, start); + let output = buffer.toString('utf8'); + if (start > 0) { + const boundary = output.indexOf('\n'); + output = boundary < 0 ? '' : output.slice(boundary + 1); + } + return boundedProviderOutput(output); + } finally { + await handle.close(); + } +} +import fs from 'node:fs'; diff --git a/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts b/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts index 1096b5331..378597f08 100644 --- a/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts +++ b/packages/core/src/agents/impl/utils/codexDockerArgsBuilder.ts @@ -1,3 +1,4 @@ +import path from 'node:path'; import logger from '../../../utils/logger.js'; import type { AgentConfig } from '../../types.js'; import { resolveConfigPath, type CodexRuntimeReasoningLevel } from '../../../config/configManager.js'; @@ -106,12 +107,49 @@ export interface CodexDockerArgsParams { reasoningLevel?: CodexRuntimeReasoningLevel | ''; readOnlyWorkspace?: boolean; repositoryInspection?: boolean; + executionMode?: 'task' | 'goal'; + resumeSessionId?: string; +} + +function resolveTaskType(params: CodexDockerArgsParams): string { + if (params.executionMode === 'goal') return 'goal'; + return params.executionType || (params.issueNumber === 0 ? 'analysis' : `issue-${params.issueNumber}`); +} + +function buildCodexCliArgs(params: CodexDockerArgsParams, streamConfig: CodexStreamConfig): string[] { + const { + executionMode = 'task', + jsonOutput = true, + reasoningLevel, + repositoryInspection = false, + resumeSessionId, + } = params; + const isGoalResume = executionMode === 'goal' && !!resumeSessionId; + return [ + 'codex', 'exec', + ...(executionMode === 'task' ? ['--ephemeral'] : []), + ...(isGoalResume ? ['resume'] : []), + ...(jsonOutput ? ['--json'] : []), + ...(repositoryInspection + ? buildCodexRepositoryScoutArgs() + : [ + '--dangerously-bypass-approvals-and-sandbox', + // Normal tasks retain their one-shot single-agent contract. + ...(executionMode === 'task' ? ['--config', 'features.multi_agent=false'] : []), + ]), + ...buildCodexStreamConfigArgs(streamConfig), + ...(reasoningLevel ? ['--config', `model_reasoning_effort="${reasoningLevel}"`] : []), + '--skip-git-repo-check', + '--cd', '/home/node/workspace', + ...(isGoalResume ? [resumeSessionId] : []), + '-', + ]; } export function buildCodexDockerArgs(config: AgentConfig, params: CodexDockerArgsParams): string[] { const { - worktreePath, githubToken, modelName, issueNumber, jsonOutput = true, environment, - taskId, executionType, reasoningLevel, readOnlyWorkspace = false, repositoryInspection = false, + worktreePath, githubToken, modelName, issueNumber, environment, + taskId, readOnlyWorkspace = false, repositoryInspection = false, } = params; if (repositoryInspection && !readOnlyWorkspace) { throw new Error('Repository inspection requires a read-only workspace'); @@ -119,14 +157,19 @@ export function buildCodexDockerArgs(config: AgentConfig, params: CodexDockerArg const dockerImage = config.dockerImage; const configPath = resolveConfigPath(config.configPath); - const envVars = buildEnvironmentVariableArgs([config.envVars, environment], repositoryInspection); + const workerOwnedGoalGit = params.executionMode === 'goal' + && environment?.PROPR_GOAL_LAUNCH_STRATEGY === 'direct'; + const envVars = buildEnvironmentVariableArgs( + [config.envVars, environment], + repositoryInspection || workerOwnedGoalGit, + ); const streamConfig = resolveCodexStreamConfig({ ...process.env, ...config.envVars, ...environment, }); const shortTaskId = createContainerExecutionId(taskId); - const taskType = executionType || (issueNumber === 0 ? 'analysis' : `issue-${issueNumber}`); + const taskType = resolveTaskType(params); const containerName = `${config.alias || 'codex'}-${taskType}-${shortTaskId}`; const workspaceTarget = repositoryInspection ? REPOSITORY_SCOUT_CONTAINER_ROOT : '/home/node/workspace'; const dockerArgs: string[] = [ @@ -139,23 +182,21 @@ export function buildCodexDockerArgs(config: AgentConfig, params: CodexDockerArg '--network', 'bridge', '--user', '0:0', '-v', `${worktreePath}:${workspaceTarget}:${readOnlyWorkspace ? 'ro' : 'rw'}`, - ...(repositoryInspection ? [] : ['-v', `/tmp/git-processor:/tmp/git-processor:${readOnlyWorkspace ? 'ro' : 'rw'}`]), + ...(workerOwnedGoalGit + ? ['-v', `${path.join(worktreePath, '.git')}:/home/node/workspace/.git:ro`] + : []), + ...(repositoryInspection ? [] : [ + '-v', `/tmp/git-processor:/tmp/git-processor:${readOnlyWorkspace || workerOwnedGoalGit ? 'ro' : 'rw'}`, + ]), '-v', `${configPath}:${CONTAINER_CONFIG_PATH}:rw`, - ...(repositoryInspection ? [] : ['-e', `GH_TOKEN=${githubToken}`, '-e', `GITHUB_TOKEN=${githubToken}`]), + ...(repositoryInspection || workerOwnedGoalGit + ? [] + : ['-e', `GH_TOKEN=${githubToken}`, '-e', `GITHUB_TOKEN=${githubToken}`]), ...(readOnlyWorkspace ? ['-e', 'PROPR_REPO_SETUP=0'] : []), ...envVars, '-w', '/home/node/workspace', dockerImage, - 'codex', 'exec', '--ephemeral', - ...(jsonOutput ? ['--json'] : []), - ...(repositoryInspection - ? buildCodexRepositoryScoutArgs() - : ['--dangerously-bypass-approvals-and-sandbox', '--config', 'features.multi_agent=false']), - ...buildCodexStreamConfigArgs(streamConfig), - ...(reasoningLevel ? ['--config', `model_reasoning_effort="${reasoningLevel}"`] : []), - '--skip-git-repo-check', - '--cd', '/home/node/workspace', - '-' + ...buildCodexCliArgs(params, streamConfig), ]; if (modelName) { @@ -169,3 +210,17 @@ export function buildCodexDockerArgs(config: AgentConfig, params: CodexDockerArg logger.info({ issueNumber, agentAlias: config.alias }, 'Docker args built for Codex agent'); return wrapDockerRunArgsWithRepoSetup(dockerArgs, dockerImage, 'codex'); } + +/** Build the same isolated goal container, but expose Codex's native JSONL App Server. */ +export function buildCodexAppServerDockerArgs(config: AgentConfig, params: CodexDockerArgsParams): string[] { + const args = buildCodexDockerArgs(config, { + ...params, + modelName: undefined, + jsonOutput: false, + executionMode: 'goal', + resumeSessionId: undefined, + }); + const codexIndex = args.lastIndexOf('codex'); + if (codexIndex < 0) throw new Error('Codex executable was not present in App Server container arguments'); + return [...args.slice(0, codexIndex), 'codex', 'app-server']; +} diff --git a/packages/core/src/agents/impl/utils/dockerArgsBuilder.ts b/packages/core/src/agents/impl/utils/dockerArgsBuilder.ts index 2c0792005..a8d2d11de 100644 --- a/packages/core/src/agents/impl/utils/dockerArgsBuilder.ts +++ b/packages/core/src/agents/impl/utils/dockerArgsBuilder.ts @@ -71,6 +71,9 @@ export interface DockerArgsParams { readOnlyWorkspace?: boolean; /** Expose only the root-confined repository scout MCP tools. */ repositoryInspection?: boolean; + /** Preserve provider state and use native session resume semantics. */ + executionMode?: 'task' | 'goal'; + resumeSessionId?: string; } function repositoryInspectionArgs(enabled: boolean): string[] { @@ -115,10 +118,14 @@ function buildBaseDockerArgs(options: { inspectionArgs: string[]; reasoningLevel?: ClaudeRuntimeReasoningLevel | ''; readOnlyWorkspace: boolean; + workerOwnedGoalGit: boolean; + executionMode: 'task' | 'goal'; + resumeSessionId?: string; }): string[] { const { config, maxTurns, worktreePath, workspaceMountTarget, configPath, containerName, githubToken, envVars, claudeJsonMount, inspectionArgs, reasoningLevel, readOnlyWorkspace, + workerOwnedGoalGit, executionMode, resumeSessionId, } = options; return [ 'run', '--rm', '-i', @@ -128,18 +135,24 @@ function buildBaseDockerArgs(options: { '--network', 'bridge', '--user', '0:0', '-v', `${worktreePath}:${workspaceMountTarget}:${readOnlyWorkspace ? 'ro' : 'rw'}`, - ...(readOnlyWorkspace ? [] : ['-v', '/tmp/git-processor:/tmp/git-processor:rw']), + ...(workerOwnedGoalGit + ? ['-v', `${path.join(worktreePath, '.git')}:/home/node/workspace/.git:ro`] + : []), + ...(readOnlyWorkspace ? [] : [ + '-v', `/tmp/git-processor:/tmp/git-processor:${workerOwnedGoalGit ? 'ro' : 'rw'}`, + ]), '-v', '/tmp/claude-logs:/tmp/claude-logs:rw', '-v', `${configPath}:/home/node/.claude:rw`, ...claudeJsonMount, - ...(readOnlyWorkspace ? [] : ['-e', `GH_TOKEN=${githubToken}`]), + ...(readOnlyWorkspace || workerOwnedGoalGit ? [] : ['-e', `GH_TOKEN=${githubToken}`]), ...(readOnlyWorkspace ? ['-e', 'PROPR_REPO_SETUP=0'] : []), ...envVars, '-w', '/home/node/workspace', config.dockerImage, 'claude', '-p', '-', - '--no-session-persistence', - '--max-turns', maxTurns.toString(), + ...(executionMode === 'task' ? ['--no-session-persistence'] : []), + ...(executionMode === 'goal' && resumeSessionId ? ['--resume', resumeSessionId] : []), + ...(executionMode === 'task' ? ['--max-turns', maxTurns.toString()] : []), '--output-format', 'stream-json', '--verbose', ...inspectionArgs, @@ -170,6 +183,7 @@ export function buildDockerArgs( const { worktreePath, githubToken, modelName, issueNumber, systemPrompt, tools, environment, taskId, executionType, reasoningLevel, readOnlyWorkspace = false, repositoryInspection = false, + executionMode = 'task', resumeSessionId, } = params; const configPath = resolveConfigPath(config.configPath); if (repositoryInspection && !readOnlyWorkspace) { @@ -181,20 +195,28 @@ export function buildDockerArgs( const workspaceMountTarget = repositoryInspection ? REPOSITORY_SCOUT_CONTAINER_ROOT : '/home/node/workspace'; - const envVars = buildEnvironmentVariableArgs([config.envVars, environment], readOnlyWorkspace); + const workerOwnedGoalGit = executionMode === 'goal' + && environment?.PROPR_GOAL_LAUNCH_STRATEGY === 'direct'; + const envVars = buildEnvironmentVariableArgs( + [config.envVars, environment], + readOnlyWorkspace || workerOwnedGoalGit, + ); const dockerArgs = buildBaseDockerArgs({ config, maxTurns, worktreePath, workspaceMountTarget, configPath, - containerName: buildClaudeContainerName(config, issueNumber, taskId, executionType), + containerName: buildClaudeContainerName(config, issueNumber, taskId, executionMode === 'goal' ? 'goal' : executionType), githubToken, envVars, claudeJsonMount: optionalClaudeJsonMount(), inspectionArgs, reasoningLevel, readOnlyWorkspace, + workerOwnedGoalGit, + executionMode, + resumeSessionId, }); // Add model parameter if specified @@ -202,7 +224,8 @@ export function buildDockerArgs( // Strip agent prefix if present (e.g., "claude:claude-opus-4-6" -> "claude-opus-4-6") const cleanModelName = modelName.includes(':') ? modelName.split(':').pop()! : modelName; const maxTurnsIndex = dockerArgs.indexOf('--max-turns'); - dockerArgs.splice(maxTurnsIndex, 0, '--model', cleanModelName); + const modelIndex = maxTurnsIndex >= 0 ? maxTurnsIndex : dockerArgs.indexOf('--output-format'); + dockerArgs.splice(modelIndex, 0, '--model', cleanModelName); logger.info({ issueNumber, requestedModel: cleanModelName, diff --git a/packages/core/src/agents/impl/utils/dockerResultProcessor.ts b/packages/core/src/agents/impl/utils/dockerResultProcessor.ts index 617f88463..452318f60 100644 --- a/packages/core/src/agents/impl/utils/dockerResultProcessor.ts +++ b/packages/core/src/agents/impl/utils/dockerResultProcessor.ts @@ -129,6 +129,7 @@ export function processDockerResult( sessionId: claudeOutput.sessionId ?? undefined, conversationId: claudeOutput.conversationId, modelUsed, + providerModel: claudeOutput.model || undefined, cost: claudeOutput.finalResult?.total_cost_usd || claudeOutput.finalResult?.cost_usd, modifiedFiles: [], commitMessage, diff --git a/packages/core/src/agents/types.ts b/packages/core/src/agents/types.ts index 604c593fd..b16bf8e8a 100644 --- a/packages/core/src/agents/types.ts +++ b/packages/core/src/agents/types.ts @@ -45,6 +45,23 @@ export interface AgentTaskOptions { issueDetails?: IssueDetails; prompt: string; + /** + * Selects the provider's native long-running goal path. Goal input is + * delivered verbatim and provider session persistence is retained. + * Omitted for the existing one-shot task behavior. + */ + executionMode?: 'task' | 'goal'; + /** Exact provider session identity to resume in goal mode. */ + resumeSessionId?: string; + /** Provider conversation identity when it differs from the session ID. */ + resumeConversationId?: string; + /** Stable initial native goal instruction used by provider goal metadata APIs. */ + nativeGoalObjective?: string; + /** Pending FIFO input consumed by the turn being started, when applicable. */ + initialControlInputId?: string; + /** Durable controls observed only at provider turn boundaries. */ + goalControl?: GoalExecutionControl; + // Execution overrides model?: string; systemPrompt?: string; @@ -52,7 +69,7 @@ export interface AgentTaskOptions { retryReason?: string; // Callbacks - onSessionId?: (sessionId: string, conversationId?: string) => void; + onSessionId?: (sessionId: string, conversationId?: string) => void | Promise; onContainerId?: (containerId: string, containerName: string) => void; // GitHub token for container @@ -78,6 +95,36 @@ export interface AgentTaskOptions { prNumber?: number; } +export interface GoalControlInput { + id: string; + message: string; +} + +export interface GoalCheckpointRequest { + id?: string; + kind: 'manual' | 'automatic'; + commitMessage?: string; +} + +export interface GoalControlSnapshot { + desiredState: 'running' | 'paused' | 'cancelled'; + requestedModel: string; + pendingInputs: GoalControlInput[]; + controlGeneration: number; + /** Direct-goal publication requested for the next safe provider boundary. */ + checkpoint: GoalCheckpointRequest | null; +} + +export interface GoalExecutionControl { + load(): Promise; + heartbeat(): Promise; + setActiveTurn(turnId: string | null): Promise; + markInputDelivered(inputId: string, turnId: string): Promise; + markInputUndeliverable(inputId: string, reason: string): Promise; + publishCheckpoint(request: GoalCheckpointRequest, turnId: string): Promise; + appendOutput(records: string[]): Promise; +} + export interface TokenUsage { input_tokens?: number; output_tokens?: number; @@ -160,6 +207,8 @@ export interface AgentExecutionResult { // Metadata modelUsed: string; + /** Model identity observed in provider output, distinct from the requested fallback. */ + providerModel?: string; /** Effective reasoning level passed to the agent runtime, when configured. */ reasoningLevel?: ReasoningLevel; sessionId?: string; @@ -191,6 +240,9 @@ export type AgentTerminationReason = 'timeout' | 'max_turns'; export interface Agent { readonly config: AgentConfig; + /** Whether this provider implements a proven durable goal/session path. */ + readonly goalCapable: boolean; + /** * Executes a complex task modifying files in the worktree. * Typically runs inside a Docker container. diff --git a/packages/core/src/claude/claudeHelpers.ts b/packages/core/src/claude/claudeHelpers.ts index 237de243b..6a297585f 100644 --- a/packages/core/src/claude/claudeHelpers.ts +++ b/packages/core/src/claude/claudeHelpers.ts @@ -4,7 +4,7 @@ import fs from 'fs'; import { Redis } from 'ioredis'; import logger from '../utils/logger.js'; import { generateClaudePrompt, IssueRef, IssueDetails } from './prompts/promptGenerator.js'; -import { executeDockerCommand, ExecutionResult } from './docker/dockerExecutor.js'; +import type { ExecutionResult } from './docker/dockerExecutor.js'; import { wrapDockerRunArgsWithRepoSetup } from './docker/repoSetupWrapper.js'; import { parseResetTimeFromMessage, calculateNextRoundHourPlus2Minutes } from '../utils/scheduling.js'; import { createContainerExecutionId } from '../agents/impl/utils/containerExecutionId.js'; @@ -156,15 +156,7 @@ export function buildClaudePrompt(options: BuildClaudePromptOptions): string { return prompt; } -export async function setWorktreeOwnership(worktreePath: string, issueNumber: number): Promise { - try { - await executeDockerCommand('sudo', ['chown', '-R', '1000:1000', worktreePath], { timeout: 10000 }); - logger.debug({ issueNumber, worktreePath }, 'Set worktree ownership to UID 1000 for container compatibility'); - } catch (chownError) { - const error = chownError as Error; - logger.warn({ issueNumber, worktreePath, error: error.message }, 'Failed to set worktree ownership - container may have permission issues'); - } -} +export { setWorktreeOwnership } from './worktreeOwnership.js'; export function verifyWorktreeStructure(worktreePath: string, issueNumber: number): string | null { const worktreeGitPath = path.join(worktreePath, '.git'); diff --git a/packages/core/src/claude/docker/dockerExecutor.ts b/packages/core/src/claude/docker/dockerExecutor.ts index cbf306bd0..d394767eb 100644 --- a/packages/core/src/claude/docker/dockerExecutor.ts +++ b/packages/core/src/claude/docker/dockerExecutor.ts @@ -1,4 +1,5 @@ import { spawn, execFileSync, SpawnOptions, ChildProcess } from 'child_process'; +import { StringDecoder } from 'node:string_decoder'; import fs from 'fs'; import { Redis } from 'ioredis'; import logger from '../../utils/logger.js'; @@ -16,6 +17,11 @@ import { scheduleForceKill, setupAbortChecker, } from './dockerAbortController.js'; +import { + BoundedProviderRecordBuffer, + boundedProviderDiagnostic, + boundedProviderOutput, +} from '../../agents/impl/utils/boundedProviderOutput.js'; export { stopDockerContainer } from './dockerContainerControl.js'; export { @@ -56,7 +62,43 @@ export interface DockerCommandOptions { signal?: AbortSignal; } -interface JsonLineMessage { type?: string; message?: { id?: string; model?: string; }; session_id?: string; conversation_id?: string; } +interface JsonLineMessage { type?: string; event?: string; message?: { id?: string; model?: string; }; session_id?: string; conversation_id?: string; thread_id?: string; init?: { conversation_id?: string }; } + +interface SessionLineInspectionContext { + messageTimestamps: Map; + state: { sessionIdDetected: boolean }; + onSessionId?: (sessionId: string, conversationId?: string) => void | Promise; + invokeExecutionCallback: (callback: () => void | Promise) => void; +} + +function resolveSessionId(message: JsonLineMessage): string | undefined { + if (message.session_id) return message.session_id; + if (message.thread_id) return message.thread_id; + if (message.event !== 'init') return undefined; + return message.conversation_id || message.init?.conversation_id; +} + +function inspectSessionMessageLine( + line: string, + timestamp: string, + context: SessionLineInspectionContext, +): void { + if (!line.trim()) return; + try { + const message: JsonLineMessage = JSON.parse(line); + if (message.type === 'assistant' || message.type === 'user') { + const messageId = message.message?.id + || `${message.type}-${JSON.stringify(message).substring(0, 100)}`; + context.messageTimestamps.set(messageId, timestamp); + } + const detectedSessionId = resolveSessionId(message); + if (!context.state.sessionIdDetected && context.onSessionId && detectedSessionId) { + context.state.sessionIdDetected = true; + const conversationId = message.conversation_id || message.init?.conversation_id; + context.invokeExecutionCallback(() => context.onSessionId!(detectedSessionId, conversationId)); + } + } catch { /* non-JSON provider output */ } +} // ANSI escape code regex for stripping terminal formatting (constructed dynamically to avoid control char lint errors) const ANSI_REGEX = new RegExp('[' + String.fromCharCode(0x1b) + String.fromCharCode(0x9b) + '][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]', 'g'); @@ -171,7 +213,10 @@ export function executeDockerCommand(command: string, args: string[], options: D const namedContainer = command === 'docker' ? getDockerRunContainerName(executionArgs) : null; const child = spawnCommandProcess(executablePath, executionArgs, cwd, stdinData); - let stdout = '', stderr = ''; + let stdout = '', stderr = '', sessionLineBuffer = ''; + const stdoutBuffer = new BoundedProviderRecordBuffer(); + const stdoutDecoder = new StringDecoder('utf8'); + const stderrDecoder = new StringDecoder('utf8'); const state = createDockerExecutionState(); let ownershipFailure: unknown; let hasOwnershipFailure = false; @@ -215,6 +260,22 @@ export function executeDockerCommand(command: string, args: string[], options: D pendingCallbacks.add(callbackPromise); void callbackPromise.finally(() => pendingCallbacks.delete(callbackPromise)); }; + const sessionInspectionContext: SessionLineInspectionContext = { + messageTimestamps, + state, + onSessionId, + invokeExecutionCallback, + }; + const inspectSessionLines = (chunk: string, timestamp: string, flush = false): void => { + sessionLineBuffer = boundedProviderOutput(sessionLineBuffer + chunk); + const lines = sessionLineBuffer.split('\n'); + const remainder = lines.pop() ?? ''; + sessionLineBuffer = flush ? '' : remainder; + if (flush && remainder) lines.push(remainder); + for (const line of lines) { + inspectSessionMessageLine(line, timestamp, sessionInspectionContext); + } + }; executionSignal?.addEventListener('abort', abortForExecutionSignal, { once: true }); const timeoutHandle = setTimeout(() => { state.timedOut = true; @@ -240,9 +301,11 @@ export function executeDockerCommand(command: string, args: string[], options: D try { extraOutput = streamExtraOutput(); } catch (err) { logger.debug({ error: (err as Error).message }, 'Failed to read extra streaming output'); } } - return extraOutput ? `${primaryOutput}${primaryOutput ? '\n' : ''}${extraOutput}` : primaryOutput; + return boundedProviderOutput( + extraOutput ? `${primaryOutput}${primaryOutput ? '\n' : ''}${extraOutput}` : primaryOutput, + ); }; - const redisState = { client: null as Redis | null, interval: null as ReturnType | null, lastLen: 0 }; + const redisState = { client: null as Redis | null, interval: null as ReturnType | null, lastOutput: '' }; if (streamToRedis && taskId) initRedisStreaming(taskId, stripAnsi, getRedisOutput, redisState); if (command === 'docker' && args[0] === 'run' && worktreePath) { containerDetectionTimer = detectContainerId( @@ -254,24 +317,20 @@ export function executeDockerCommand(command: string, args: string[], options: D } child.stdout?.on('data', (data: Buffer) => { - const chunk = data.toString(), ts = new Date().toISOString(); - stdout += chunk; - for (const line of chunk.split('\n')) { - if (!line.trim()) continue; - try { - const j: JsonLineMessage = JSON.parse(line); - if (j.type === 'assistant' || j.type === 'user') messageTimestamps.set(j.message?.id || `${j.type}-${JSON.stringify(j).substring(0, 100)}`, ts); - if (!state.sessionIdDetected && onSessionId && j.session_id) { - state.sessionIdDetected = true; - invokeExecutionCallback(() => onSessionId(j.session_id!, j.conversation_id)); - } - } catch { /* skip */ } - } + const chunk = stdoutDecoder.write(data), ts = new Date().toISOString(); + stdout = stdoutBuffer.append(chunk); + inspectSessionLines(chunk, ts); + }); + child.stderr?.on('data', (data: Buffer) => { + stderr = boundedProviderDiagnostic(stderr + stderrDecoder.write(data)); }); - child.stderr?.on('data', (data: Buffer) => { stderr += data.toString(); }); child.on('close', async (exitCode: number | null) => { clearTimeout(timeoutHandle); + const finalStdout = stdoutDecoder.end(); + if (finalStdout) stdout = stdoutBuffer.append(finalStdout); + stderr = boundedProviderDiagnostic(stderr + stderrDecoder.end()); + inspectSessionLines(finalStdout, new Date().toISOString(), true); if (containerDetectionTimer) clearTimeout(containerDetectionTimer); if (abortChecker) await abortChecker.close(); await Promise.allSettled([...pendingCallbacks]); @@ -302,6 +361,7 @@ export function executeDockerCommand(command: string, args: string[], options: D }); child.on('error', async (error: Error) => { clearTimeout(timeoutHandle); + inspectSessionLines('', new Date().toISOString(), true); if (containerDetectionTimer) clearTimeout(containerDetectionTimer); executionSignal?.removeEventListener('abort', abortForExecutionSignal); if (abortChecker) await abortChecker.close(); @@ -314,15 +374,15 @@ export function executeDockerCommand(command: string, args: string[], options: D }); } -function initRedisStreaming(taskId: string, stripAnsi: boolean | undefined, getStdout: () => string, state: { client: Redis | null; interval: ReturnType | null; lastLen: number }): void { +function initRedisStreaming(taskId: string, stripAnsi: boolean | undefined, getStdout: () => string, state: { client: Redis | null; interval: ReturnType | null; lastOutput: string }): void { (async () => { try { state.client = new Redis({ host: process.env.REDIS_HOST || 'redis', port: parseInt(process.env.REDIS_PORT || '6379', 10) }); const redisKey = `agent:output:${taskId}`; state.interval = setInterval(async () => { const stdout = getStdout(); - if (stdout.length > state.lastLen && state.client) { - try { await state.client.setex(redisKey, 3600, stripAnsi ? stripAnsiCodes(stdout) : stdout); state.lastLen = stdout.length; } + if (stdout !== state.lastOutput && state.client) { + try { await state.client.setex(redisKey, 3600, stripAnsi ? stripAnsiCodes(stdout) : stdout); state.lastOutput = stdout; } catch (err) { logger.debug({ error: (err as Error).message }, 'Failed to stream output to Redis'); } } }, 2000); diff --git a/packages/core/src/claude/worktreeOwnership.ts b/packages/core/src/claude/worktreeOwnership.ts new file mode 100644 index 000000000..a87d859cb --- /dev/null +++ b/packages/core/src/claude/worktreeOwnership.ts @@ -0,0 +1,21 @@ +import path from 'path'; +import logger from '../utils/logger.js'; +import { executeDockerCommand } from './docker/dockerExecutor.js'; + +export async function setWorktreeOwnership( + worktreePath: string, + issueNumber: number, + options: { protectGitMetadata?: boolean } = {}, +): Promise { + try { + await executeDockerCommand('sudo', ['chown', '-R', '1000:1000', worktreePath], { timeout: 10000 }); + if (options.protectGitMetadata) { + await executeDockerCommand('sudo', ['chown', 'root:root', path.join(worktreePath, '.git')], { timeout: 10000 }); + } + logger.debug({ issueNumber, worktreePath }, 'Set worktree ownership to UID 1000 for container compatibility'); + } catch (chownError) { + const error = chownError as Error; + logger.warn({ issueNumber, worktreePath, error: error.message }, 'Failed to set worktree ownership - container may have permission issues'); + if (options.protectGitMetadata) throw error; + } +} diff --git a/packages/core/src/db/migrations/20260902000000_create_goals.js b/packages/core/src/db/migrations/20260902000000_create_goals.js new file mode 100644 index 000000000..2246625e1 --- /dev/null +++ b/packages/core/src/db/migrations/20260902000000_create_goals.js @@ -0,0 +1,49 @@ +/** + * Minimal durable envelope for native coding-agent goals. Task history, output, + * todos, token usage, containers and execution logs remain in their existing + * stores and are referenced through current_task_id. + */ +export async function up(knex) { + await knex.schema.createTable('goals', table => { + table.uuid('goal_id').primary(); + table.string('owner_id', 255).notNullable(); + table.string('owner_login', 255).notNullable(); + table.string('repository', 255).notNullable(); + table.text('objective').notNullable(); + table.string('launch_strategy', 20).notNullable(); + table.text('initial_prompt').notNullable(); + table.string('base_branch', 255); + table.string('branch_name', 255); + table.text('worktree_path'); + table.string('agent_id', 255).notNullable(); + table.string('agent_alias', 255).notNullable(); + table.string('agent_type', 50).notNullable(); + table.string('requested_model', 255).notNullable(); + table.string('effective_model', 255); + table.integer('max_parallel_tasks'); + table.boolean('ultrafix'); + table.string('desired_state', 20).notNullable().defaultTo('running'); + table.string('result_state', 20); + table.string('current_task_id', 255).notNullable().unique(); + table.string('session_id', 255); + table.string('conversation_id', 255); + table.integer('run_generation').notNullable().defaultTo(0); + table.integer('final_pr_number'); + table.text('final_pr_url'); + table.json('artifact_refs').defaultTo('[]'); + table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable(); + table.timestamp('updated_at').defaultTo(knex.fn.now()).notNullable(); + table.timestamp('started_at'); + table.timestamp('paused_at'); + table.bigInteger('paused_ms').notNullable().defaultTo(0); + table.timestamp('completed_at'); + + table.index(['owner_id', 'updated_at']); + table.index(['desired_state', 'result_state']); + table.index('repository'); + }); +} + +export async function down(knex) { + await knex.schema.dropTableIfExists('goals'); +} diff --git a/packages/core/src/db/migrations/20260902010000_harden_native_goals.js b/packages/core/src/db/migrations/20260902010000_harden_native_goals.js new file mode 100644 index 000000000..95cb46d48 --- /dev/null +++ b/packages/core/src/db/migrations/20260902010000_harden_native_goals.js @@ -0,0 +1,72 @@ +/** + * Operational fencing and steering for the single provider-owned goal session. + * These records are transport state, not a ProPR planning graph. + */ +export async function up(knex) { + await knex.schema.alterTable('goals', table => { + table.uuid('run_claim'); + table.timestamp('claimed_at'); + table.timestamp('attempt_heartbeat_at'); + table.string('active_turn_id', 255); + table.timestamp('pause_confirmed_at'); + table.boolean('resume_requested').notNullable().defaultTo(false); + table.string('create_idempotency_key', 255); + table.string('create_idempotency_operation', 100); + table.string('create_payload_hash', 64); + table.integer('control_generation').notNullable().defaultTo(0); + table.integer('control_ack_generation').notNullable().defaultTo(0); + table.timestamp('task_reconciled_at'); + table.text('failure_reason'); + table.json('artifact_stats').defaultTo('{}'); + table.timestamp('artifacts_checked_at'); + table.unique(['owner_id', 'create_idempotency_key']); + table.index(['desired_state', 'result_state', 'attempt_heartbeat_at']); + }); + + await knex.schema.createTable('goal_inputs', table => { + table.increments('sequence').primary(); + table.uuid('input_id').notNullable().unique(); + table.uuid('goal_id').notNullable().references('goal_id').inTable('goals').onDelete('CASCADE'); + table.string('owner_id', 255).notNullable(); + table.string('idempotency_key', 255).notNullable(); + table.string('operation', 100).notNullable(); + table.string('payload_hash', 64).notNullable(); + table.string('kind', 20).notNullable().defaultTo('input'); + table.text('message').notNullable(); + table.string('state', 20).notNullable().defaultTo('pending'); + table.integer('delivered_generation'); + table.string('delivered_claim', 255); + table.string('delivered_turn_id', 255); + table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable(); + table.timestamp('delivered_at'); + table.text('delivery_error'); + + table.unique(['owner_id', 'idempotency_key']); + table.index(['goal_id', 'state', 'sequence']); + }); +} + +export async function down(knex) { + await knex.schema.dropTableIfExists('goal_inputs'); + await knex.schema.alterTable('goals', table => { + table.dropUnique(['owner_id', 'create_idempotency_key']); + table.dropIndex(['desired_state', 'result_state', 'attempt_heartbeat_at']); + table.dropColumns( + 'run_claim', + 'claimed_at', + 'attempt_heartbeat_at', + 'active_turn_id', + 'pause_confirmed_at', + 'resume_requested', + 'create_idempotency_key', + 'create_idempotency_operation', + 'create_payload_hash', + 'control_generation', + 'control_ack_generation', + 'task_reconciled_at', + 'failure_reason', + 'artifact_stats', + 'artifacts_checked_at', + ); + }); +} diff --git a/packages/core/src/db/migrations/20260903000000_add_direct_goal_checkpoints.js b/packages/core/src/db/migrations/20260903000000_add_direct_goal_checkpoints.js new file mode 100644 index 000000000..407c689b5 --- /dev/null +++ b/packages/core/src/db/migrations/20260903000000_add_direct_goal_checkpoints.js @@ -0,0 +1,59 @@ +/** + * Worker-owned publication checkpoints for goals that implement directly. + * Orchestrated goals continue to own their GitHub issue/PR artifacts. + */ +export async function up(knex) { + await knex.schema.alterTable('goals', table => { + table.integer('checkpoint_interval_minutes'); + table.timestamp('last_checkpoint_at'); + table.string('last_checkpoint_commit_sha', 64); + table.integer('checkpoint_count').notNullable().defaultTo(0); + table.text('checkpoint_error'); + }); + + await knex.schema.createTable('goal_checkpoints', table => { + table.uuid('checkpoint_id').primary(); + table.uuid('goal_id').notNullable().references('goal_id').inTable('goals').onDelete('CASCADE'); + table.string('owner_id', 255).notNullable(); + table.string('idempotency_key', 255).notNullable(); + table.string('operation', 100).notNullable(); + table.string('payload_hash', 64).notNullable(); + table.string('kind', 20).notNullable(); + table.text('commit_message'); + table.string('state', 20).notNullable().defaultTo('pending'); + table.integer('requested_generation').notNullable(); + table.string('requested_claim', 255); + table.string('delivered_turn_id', 255); + table.string('commit_sha', 64); + table.integer('pr_number'); + table.text('pr_url'); + table.text('error'); + table.timestamp('created_at').defaultTo(knex.fn.now()).notNullable(); + table.timestamp('started_at'); + table.timestamp('completed_at'); + + table.unique(['owner_id', 'idempotency_key']); + table.index(['goal_id', 'state', 'created_at']); + }); + + await knex('goals') + .where({ launch_strategy: 'direct' }) + .whereNull('checkpoint_interval_minutes') + .update({ + checkpoint_interval_minutes: 15, + last_checkpoint_at: knex.raw('COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)'), + }); +} + +export async function down(knex) { + await knex.schema.dropTableIfExists('goal_checkpoints'); + await knex.schema.alterTable('goals', table => { + table.dropColumns( + 'checkpoint_interval_minutes', + 'last_checkpoint_at', + 'last_checkpoint_commit_sha', + 'checkpoint_count', + 'checkpoint_error', + ); + }); +} diff --git a/packages/core/src/git/commitOperations.ts b/packages/core/src/git/commitOperations.ts index 0f58fce2a..c8de7080b 100644 --- a/packages/core/src/git/commitOperations.ts +++ b/packages/core/src/git/commitOperations.ts @@ -22,6 +22,8 @@ interface CommitMessageObject { interface CommitOptions { issueNumber?: number; issueTitle?: string; + /** Create an empty commit when a remote branch must exist before agent edits begin. */ + allowEmpty?: boolean; } export interface CommitResult { @@ -102,7 +104,7 @@ function resolveCommitMessage(commitMessage: string | CommitMessageObject, issue } export async function commitChanges(worktreePath: string, commitMessage: string | CommitMessageObject, author: Author | null, options: CommitOptions = {}): Promise { - const { issueNumber, issueTitle } = options; + const { issueNumber, issueTitle, allowEmpty = false } = options; try { await validateWorktree(worktreePath, issueNumber); } catch (validationError) { @@ -127,10 +129,11 @@ export async function commitChanges(worktreePath: string, commitMessage: string } } const status = await git.status(); + const stagedFiles = status.files.filter((file: FileStatusResult) => file.index !== ' ' && file.index !== '?'); logGitStatus(status, worktreePath, issueNumber); - if (status.files.length === 0) { + if (stagedFiles.length === 0 && !allowEmpty) { logger.info({ worktreePath }, 'No changes to commit'); return null; } @@ -138,21 +141,25 @@ export async function commitChanges(worktreePath: string, commitMessage: string logger.info({ worktreePath, issueNumber, - totalFiles: status.files.length, - files: status.files.map((f: FileStatusResult) => ({ path: f.path, index: f.index, working_dir: f.working_dir })) + totalFiles: stagedFiles.length, + files: stagedFiles.map((f: FileStatusResult) => ({ path: f.path, index: f.index, working_dir: f.working_dir })) }, 'Files to be committed'); const finalCommitMessage = resolveCommitMessage(commitMessage, issueNumber, issueTitle); - const result = await git.commit(finalCommitMessage); - const commitHash = result.commit.replace(/^HEAD\s+/, ''); + const result = allowEmpty && stagedFiles.length === 0 + ? await git.raw(['commit', '--allow-empty', '-m', finalCommitMessage]) + : await git.commit(finalCommitMessage); + const commitHash = typeof result === 'string' + ? (await git.revparse(['HEAD'])).trim() + : result.commit.replace(/^HEAD\s+/, ''); - logger.info({ worktreePath, commitHash, filesChanged: status.files.length, issueNumber, commitMessage: finalCommitMessage }, 'Changes committed successfully'); + logger.info({ worktreePath, commitHash, filesChanged: stagedFiles.length, issueNumber, commitMessage: finalCommitMessage }, 'Changes committed successfully'); return { commitHash, commitMessage: finalCommitMessage, - filesChanged: status.files.map((file: FileStatusResult) => file.path) + filesChanged: stagedFiles.map((file: FileStatusResult) => file.path) }; } catch (error) { diff --git a/packages/core/src/goalExports.ts b/packages/core/src/goalExports.ts new file mode 100644 index 000000000..254b9fc68 --- /dev/null +++ b/packages/core/src/goalExports.ts @@ -0,0 +1,42 @@ +export { + GOAL_CAPABILITY_COMMANDS, + antigravityConversationIdentity, + antigravityHelpSupportsWholeSession, + claudeSessionIdentity, + claudeHelpSupportsWholeSession, + codexHandshakeSupportsNativeGoal, + codexSchemaSupportsNativeGoal, + probeGoalCapability, + GoalCapabilityProbe, + type GoalCapability, +} from './agents/goalCapabilities.js'; +export { + GOAL_LAUNCH_STRATEGIES, + GOAL_CONTINUE_INPUT, + CODEX_GOAL_OBJECTIVE_MAX_LENGTH, + DEFAULT_GOAL_CHECKPOINT_INTERVAL_MINUTES, + MIN_GOAL_CHECKPOINT_INTERVAL_MINUTES, + MAX_GOAL_CHECKPOINT_INTERVAL_MINUTES, + buildGoalPolicyEnvironment, + buildNativeGoalCommand, + codexGoalPromptValidationError, + goalJobId, + goalAttemptLabel, + type GoalDesiredState, + type GoalLaunchStrategy, + type GoalResultState, +} from './goals.js'; +export type { GoalJobData } from './queue/taskQueue.types.js'; +export type { + GoalCheckpointRequest, + GoalControlInput, + GoalControlSnapshot, + GoalExecutionControl, +} from './agents/types.js'; +export { + discoverRepositoryArtifacts, + parseGoalArtifacts, + validateGoalArtifacts, + type GoalArtifact, + type GoalArtifactStats, +} from './goals/goalArtifacts.js'; diff --git a/packages/core/src/goals.ts b/packages/core/src/goals.ts new file mode 100644 index 000000000..cd32a6b0e --- /dev/null +++ b/packages/core/src/goals.ts @@ -0,0 +1,80 @@ +export type GoalDesiredState = 'running' | 'paused' | 'cancelled'; +export type GoalResultState = 'completed' | 'failed' | 'cancelled'; +export const GOAL_LAUNCH_STRATEGIES = ['direct', 'orchestrate'] as const; +export type GoalLaunchStrategy = typeof GOAL_LAUNCH_STRATEGIES[number]; + +export const GOAL_CONTINUE_INPUT = 'Continue working toward the goal.'; +export const CODEX_GOAL_OBJECTIVE_MAX_LENGTH = 4_000; +export const DEFAULT_GOAL_CHECKPOINT_INTERVAL_MINUTES = 15; +export const MIN_GOAL_CHECKPOINT_INTERVAL_MINUTES = 5; +export const MAX_GOAL_CHECKPOINT_INTERVAL_MINUTES = 120; + +/** Codex measures goal objectives as Unicode code points, not UTF-16 units. */ +export function codexGoalPromptValidationError(prompt: string): string | null { + return Array.from(prompt).length > CODEX_GOAL_OBJECTIVE_MAX_LENGTH + ? `Final Codex goal prompt must be at most ${CODEX_GOAL_OBJECTIVE_MAX_LENGTH} Unicode characters` + : null; +} + +const launchInstructions: Record = { + direct: [ + 'Launch strategy — Agent implements directly:', + 'Implement the goal yourself in the prepared worktree. ProPR creates the draft PR before execution and owns all commits and pushes.', + 'Do not run git commit, git push, change branches, rewrite .git metadata, or create another implementation PR.', + 'Finish coherent provider turns as work progresses so ProPR can publish safe checkpoint commits to the draft PR.', + ].join('\n'), + orchestrate: [ + 'Launch strategy — Agent orchestrates through ProPR:', + 'Drive delivery by deciding the decomposition and hierarchy yourself, creating GitHub issues, and starting and monitoring their implementation through ProPR.', + 'For a large delivery, organize the work into an epic PR and, when useful, sub-epic and issue PRs. You—not a ProPR planner—own every planning and hierarchy decision.', + ].join('\n'), +}; + +/** Build the exact first input for the single provider-native goal session. */ +export function buildNativeGoalCommand(options: { + objective: string; + launchStrategy: GoalLaunchStrategy; + maxParallelTasks?: number | null; + ultrafix?: boolean | null; +}): string { + const parallelPolicy = options.maxParallelTasks == null + ? 'Concurrency policy: No maximum parallel task count was selected. Decide and manage concurrency yourself; ProPR does not schedule a plan graph.' + : `Concurrency policy: Run at most ${options.maxParallelTasks} implementation tasks in parallel. Decide what to parallelize and enforce this limit yourself; ProPR does not schedule a plan graph.`; + const ultrafixPolicy = options.ultrafix + ? 'Ultrafix policy: Enabled. Run Ultrafix as part of delivery before final completion.' + : 'Ultrafix policy: Disabled. Do not run Ultrafix unless later steering input explicitly requests it.'; + const deliveryRequirements = options.launchStrategy === 'direct' + ? [ + '- Finish with validated implementation files; ProPR publishes and validates the final checkpoint on its draft PR.', + '- Report any GitHub artifact you intentionally create so ProPR can record it.', + ] + : [ + '- Finish with a draft PR containing the final implementation.', + '- Track every GitHub issue and PR you create, validate that each artifact exists and is in the expected state, and report its URL so ProPR can record it.', + '- Validate the final draft PR and its related artifacts before declaring the goal complete.', + ]; + return [ + `/goal ${options.objective}`, + '', + launchInstructions[options.launchStrategy], + parallelPolicy, + ultrafixPolicy, + 'Delivery requirements:', + ...deliveryRequirements, + ].join('\n'); +} + +export function goalJobId(goalId: string, generation: number): string { + return `goal-${goalId}-${generation}`; +} + +export function goalAttemptLabel(generation: number, claimId: string): string { + return `${generation}:${claimId}`; +} + +export function buildGoalPolicyEnvironment(launchStrategy?: GoalLaunchStrategy): Record { + return { + PROPR_EXECUTION_MODE: 'goal', + ...(launchStrategy ? { PROPR_GOAL_LAUNCH_STRATEGY: launchStrategy } : {}), + }; +} diff --git a/packages/core/src/goals/goalArtifacts.ts b/packages/core/src/goals/goalArtifacts.ts new file mode 100644 index 000000000..42ab487c5 --- /dev/null +++ b/packages/core/src/goals/goalArtifacts.ts @@ -0,0 +1,158 @@ +import { getAuthenticatedOctokit } from '../auth/githubAuth.js'; + +export interface GoalArtifact { + type: 'pull_request' | 'issue'; + number: number; + url: string; + state?: string; + draft?: boolean; +} + +export interface GoalArtifactStats { + issues: number; + openIssues: number; + pullRequests: number; + openPullRequests: number; +} + +interface GoalArtifactContext { + repository: string; + branchName: string | null; + baseBranch: string | null; +} + +type Octokit = Awaited>; + +function repositoryUrlPattern(repository: string): RegExp { + const escaped = repository.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`https://github\\.com/${escaped}/(pull|issues)/(\\d+)\\b`, 'gi'); +} + +function isRepositoryArtifact(repository: string, artifact: GoalArtifact): boolean { + if (!Number.isSafeInteger(artifact.number) || artifact.number < 1) return false; + const path = artifact.type === 'pull_request' ? 'pull' : artifact.type === 'issue' ? 'issues' : null; + if (!path) return false; + return artifact.url === `https://github.com/${repository}/${path}/${artifact.number}`; +} + +export function discoverRepositoryArtifacts(repository: string, output: string): GoalArtifact[] { + const artifacts = new Map(); + for (const match of output.matchAll(repositoryUrlPattern(repository))) { + const url = `https://github.com/${repository}/${match[1].toLowerCase()}/${match[2]}`; + artifacts.set(url, { + type: match[1].toLowerCase() === 'pull' ? 'pull_request' : 'issue', + number: Number(match[2]), + url, + }); + } + return [...artifacts.values()]; +} + +export function parseGoalArtifacts(value: string | GoalArtifact[] | null | undefined): GoalArtifact[] { + if (Array.isArray(value)) return value; + if (typeof value !== 'string') return []; + try { + const parsed = JSON.parse(value) as unknown; + return Array.isArray(parsed) ? parsed as GoalArtifact[] : []; + } catch { + return []; + } +} + +async function validateArtifact( + octokit: Octokit, + owner: string, + repo: string, + artifact: GoalArtifact, +): Promise { + try { + if (artifact.type === 'pull_request') { + const response = await octokit.request('GET /repos/{owner}/{repo}/pulls/{pull_number}', { + owner, repo, pull_number: artifact.number, + }); + return { + ...artifact, + url: response.data.html_url, + state: response.data.state, + draft: response.data.draft ?? false, + }; + } + const response = await octokit.request('GET /repos/{owner}/{repo}/issues/{issue_number}', { + owner, repo, issue_number: artifact.number, + }); + if ('pull_request' in response.data) return null; + return { ...artifact, url: response.data.html_url, state: response.data.state }; + } catch { + return null; + } +} + +async function findExpectedFinalPr( + octokit: Octokit, + context: GoalArtifactContext, +): Promise { + if (!context.branchName) return undefined; + const [owner, repo] = context.repository.split('/'); + let base = context.baseBranch; + if (!base) { + const repository = await octokit.request('GET /repos/{owner}/{repo}', { owner, repo }); + base = repository.data.default_branch; + } + const response = await octokit.request('GET /repos/{owner}/{repo}/pulls', { + owner, + repo, + state: 'open', + head: `${owner}:${context.branchName}`, + base, + per_page: 100, + }); + const pull = response.data.find(candidate => + candidate.head.ref === context.branchName + && candidate.base.ref === base + && candidate.draft === true + && candidate.merged_at == null); + return pull ? { + type: 'pull_request', + number: pull.number, + url: pull.html_url, + state: pull.state, + draft: true, + } : undefined; +} + +function artifactStats(artifacts: GoalArtifact[]): GoalArtifactStats { + const issues = artifacts.filter(artifact => artifact.type === 'issue'); + const pullRequests = artifacts.filter(artifact => artifact.type === 'pull_request'); + return { + issues: issues.length, + openIssues: issues.filter(artifact => artifact.state === 'open').length, + pullRequests: pullRequests.length, + openPullRequests: pullRequests.filter(artifact => artifact.state === 'open').length, + }; +} + +export async function validateGoalArtifacts(options: { + context: GoalArtifactContext; + existing: GoalArtifact[]; + output: string; + octokit?: Octokit; +}): Promise<{ artifacts: GoalArtifact[]; stats: GoalArtifactStats; finalPr?: GoalArtifact }> { + const octokit = options.octokit ?? await getAuthenticatedOctokit(); + const [owner, repo] = options.context.repository.split('/'); + const candidates = new Map(); + for (const artifact of [...options.existing, ...discoverRepositoryArtifacts(options.context.repository, options.output)]) { + if (!isRepositoryArtifact(options.context.repository, artifact)) continue; + candidates.set(`${artifact.type}:${artifact.number}`, artifact); + } + const validated = (await Promise.all( + [...candidates.values()].map(artifact => validateArtifact(octokit, owner, repo, artifact)), + )).filter((artifact): artifact is GoalArtifact => artifact !== null); + const finalPr = await findExpectedFinalPr(octokit, options.context); + if (finalPr) { + const key = `pull_request:${finalPr.number}`; + const index = validated.findIndex(artifact => `${artifact.type}:${artifact.number}` === key); + if (index >= 0) validated[index] = finalPr; + else validated.push(finalPr); + } + return { artifacts: validated, stats: artifactStats(validated), finalPr }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 35e9bb9e6..530522898 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -316,6 +316,7 @@ export { processDetectedIssue, fetchIssuesForRepo } from './daemon/issueDetectio // Agent abstraction exports export { AgentRegistry, getAgentRegistry, type AgentRegistryOperationalStatus } from './agents/AgentRegistry.js'; +export * from './goalExports.js'; export * from './agents/syntheticRouting.js'; export { describeAgentTermination, isIncompleteAgentExecution, resolveAgentTerminationReason } from './agents/termination.js'; export { ClaudeAgent } from './agents/impl/ClaudeAgent.js'; diff --git a/packages/core/src/queue/taskQueue.ts b/packages/core/src/queue/taskQueue.ts index 0019a8146..4f4bf8d08 100644 --- a/packages/core/src/queue/taskQueue.ts +++ b/packages/core/src/queue/taskQueue.ts @@ -10,6 +10,7 @@ export type { CommentJobData, UnprocessedComment, TaskImportJobData, + GoalJobData, AnalysisJobData, SystemTaskJobData, IndexingJobData, @@ -30,6 +31,7 @@ export type { import type { IssueJobData, CommentJobData, + GoalJobData, AnalysisJobData, IndexingJobData, JobData, @@ -59,7 +61,7 @@ const connectionOptions: RedisOptions = { // Lazy-initialized Redis connection and queues let redisConnection: Redis | null = null; -let _issueQueue: Queue | null = null; +let _issueQueue: Queue | null = null; let _analysisQueue: Queue | null = null; let _indexingQueue: Queue | null = null; let isInitialized = false; @@ -106,7 +108,7 @@ async function ensureInitialized(): Promise { }, }; - _issueQueue = new Queue(GITHUB_ISSUE_QUEUE_NAME, issueQueueOptions); + _issueQueue = new Queue(GITHUB_ISSUE_QUEUE_NAME, issueQueueOptions); _issueQueue.on('error', (err: Error) => { logger.error({ queue: GITHUB_ISSUE_QUEUE_NAME, err }, 'Queue error'); }); @@ -162,7 +164,7 @@ async function ensureInitialized(): Promise { /** * Get the issue queue, initializing if needed. */ -export async function getIssueQueue(): Promise> { +export async function getIssueQueue(): Promise> { await ensureInitialized(); return _issueQueue!; } @@ -186,7 +188,7 @@ export async function getIndexingQueue(): Promise> { // Legacy synchronous exports for backward compatibility // These will throw if accessed before initialization // Use getIssueQueue(), getAnalysisQueue(), getIndexingQueue() for safe access -export const issueQueue = new Proxy({} as Queue, { +export const issueQueue = new Proxy({} as Queue, { get(_target, prop) { if (!_issueQueue) { throw new Error('issueQueue accessed before initialization. Use getIssueQueue() instead or call ensureInitialized() first.'); diff --git a/packages/core/src/queue/taskQueue.types.ts b/packages/core/src/queue/taskQueue.types.ts index 09058f621..9f2175874 100644 --- a/packages/core/src/queue/taskQueue.types.ts +++ b/packages/core/src/queue/taskQueue.types.ts @@ -101,6 +101,22 @@ export interface TaskImportJobData { user?: string; } +/** One continuation of the same native provider goal task/session. */ +export interface GoalJobData { + goalId: string; + taskId: string; + repoOwner: string; + repoName: string; + generation: number; + /** Opaque durable claim for this exact generation. */ + claimId: string; + /** Exact initial native command or an ordinary same-session continuation. */ + input?: string; + recovery?: boolean; + /** Ordinary replies do not by themselves declare the provider-owned goal complete. */ + continuationKind?: 'run' | 'input'; +} + export interface AnalysisJobData { taskId: string; executionId: string; @@ -151,7 +167,7 @@ export interface MergeConflictJobData { systemGenerated: true; // Distinguishes from user-authored follow-up comments } -export type JobData = IssueJobData | CommentJobData | TaskImportJobData | AnalysisJobData | SystemTaskJobData | IndexingJobData | MergeConflictJobData; +export type JobData = IssueJobData | CommentJobData | TaskImportJobData | GoalJobData | AnalysisJobData | SystemTaskJobData | IndexingJobData | MergeConflictJobData; export interface ClaudeOutputResult { type?: string; diff --git a/packages/core/test/antigravityConfig.test.ts b/packages/core/test/antigravityConfig.test.ts index 3dc3364ef..5525c8b32 100644 --- a/packages/core/test/antigravityConfig.test.ts +++ b/packages/core/test/antigravityConfig.test.ts @@ -450,11 +450,9 @@ test('Antigravity agent rejects stream results from a different conversation', a prompt: string; worktreePath: string; worktreeGitContent: null; - onSessionId: (sessionId: string, conversationId?: string) => void; }): Promise<{ success: boolean; error?: string; sessionId?: string; conversationId?: string; conversationLog?: unknown[]; modelUsed?: string }>; }; internals.persistImplementationLog = async () => undefined; - let callbackIdentity: [string, string | undefined] | undefined; const result = await internals.processExecutionResult({ result: { stdout, stderr: '', exitCode: 0 }, @@ -464,7 +462,6 @@ test('Antigravity agent rejects stream results from a different conversation', a prompt: 'test', worktreePath: '/tmp', worktreeGitContent: null, - onSessionId: (sessionId, conversationId) => { callbackIdentity = [sessionId, conversationId]; }, }); assert.equal(result.success, false); @@ -472,7 +469,6 @@ test('Antigravity agent rejects stream results from a different conversation', a assert.equal(result.sessionId, 'conversation-sanitized'); assert.equal(result.conversationId, 'conversation-sanitized'); assert.equal(result.modelUsed, 'antigravity-gemini-3.7-flash-high'); - assert.deepEqual(callbackIdentity, ['conversation-sanitized', 'conversation-sanitized']); }); test('Antigravity agent rejects differing stdout and transcript conversation identities', async () => { @@ -828,6 +824,7 @@ test('Antigravity session recovery reads and removes the exported transient tran const transcriptPath = path.join(tempDir, 'transcript.jsonl'); await fs.promises.writeFile( transcriptPath, + `${'x'.repeat(1024 * 1024 + 100)}\n` + JSON.stringify({ step_index: 2, source: 'MODEL', diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts index 9d6f87aef..db2a23325 100644 --- a/packages/shared/src/events.ts +++ b/packages/shared/src/events.ts @@ -153,6 +153,8 @@ export interface TaskLiveUpdatePayload { export interface QueueStatsData { waiting: number; active: number; + /** Active native goal jobs included in the aggregate active count. */ + activeGoals?: number; completed: number; failed: number; delayed: number; diff --git a/propr-ui/src/App.tsx b/propr-ui/src/App.tsx index 76cebf0f3..33dedac22 100644 --- a/propr-ui/src/App.tsx +++ b/propr-ui/src/App.tsx @@ -35,6 +35,7 @@ const RevertPage = lazy(() => import('./pages/RevertPage')) const SettingsPage = lazy(() => import('./pages/SettingsPage')) const SummaryBrowserPage = lazy(() => import('./pages/SummaryBrowserPage')) const TasksPage = lazy(() => import('./pages/TasksPage')) +const GoalsPage = lazy(() => import('./pages/GoalsPage')) type CompatibilityState = | { status: 'checking' } @@ -269,6 +270,8 @@ const AppContent: React.FC = () => { } /> + } /> + } /> ; + tokenUsage: { input_tokens?: number; output_tokens?: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number } | null; + nativeGoal: { objective: string; status: string; tokenBudget: number | null; tokensUsed: number; timeUsedSeconds: number } | null; + }; + taskState: string; + createdAt: string; + updatedAt: string; + startedAt: string | null; + pausedAt: string | null; + completedAt: string | null; + elapsedMs: number; + pausedMs: number; + activeMs: number; +} + +async function request(path: string, init?: RequestInit): Promise { + const requestInit = { + credentials: 'include' as const, + ...init, + headers: init?.body ? { 'Content-Type': 'application/json', ...init.headers } : init?.headers, + }; + const retryable = new Headers(requestInit.headers).has('Idempotency-Key'); + let lastError: unknown; + for (let attempt = 0; attempt < (retryable ? 2 : 1); attempt += 1) { + try { + const response = await apiFetch(`${API_BASE_URL}${path}`, requestInit); + await handleApiResponse(response); + return response.json(); + } catch (error) { + lastError = error; + } + } + throw lastError; +} + +const idempotentMutation = (method: string, body?: unknown): RequestInit => ({ + method, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + headers: { 'Idempotency-Key': crypto.randomUUID() }, +}); + +export const getGoalCapabilities = async (recheck = false) => + request<{ agents: GoalCapability[] }>(`/api/goals/capabilities${recheck ? '?recheck=true' : ''}`); +export const listGoals = async () => request<{ goals: Goal[] }>('/api/goals'); +export const getGoal = async (id: string) => request<{ goal: Goal }>(`/api/goals/${encodeURIComponent(id)}`); +export const deleteGoal = async (id: string): Promise => { + const response = await apiFetch(`${API_BASE_URL}/api/goals/${encodeURIComponent(id)}`, { + method: 'DELETE', credentials: 'include', + }); + await handleApiResponse(response); +}; +export const createGoal = async (body: { repository: string; objective: string; launchStrategy: GoalLaunchStrategy; agentId: string; model: string; baseBranch?: string; maxParallelTasks?: number; ultrafix?: boolean; checkpointIntervalMinutes?: number }) => + request<{ goal: Goal }>('/api/goals', idempotentMutation('POST', body)); +export const pauseGoal = async (id: string) => request<{ goal: Goal }>(`/api/goals/${encodeURIComponent(id)}/pause`, idempotentMutation('POST')); +export const resumeGoal = async (id: string) => request<{ goal: Goal }>(`/api/goals/${encodeURIComponent(id)}/resume`, idempotentMutation('POST')); +export const cancelGoal = async (id: string) => request<{ goal: Goal }>(`/api/goals/${encodeURIComponent(id)}/cancel`, idempotentMutation('POST')); +export const requestGoalModel = async (id: string, model: string) => request<{ goal: Goal }>(`/api/goals/${encodeURIComponent(id)}/model`, idempotentMutation('PATCH', { model })); +export const sendGoalInput = async (id: string, body: { message?: string; canned?: 'done' | 'left' }) => request<{ goal: Goal }>(`/api/goals/${encodeURIComponent(id)}/input`, idempotentMutation('POST', body)); +export const checkpointGoal = async (id: string, commitMessage?: string) => request<{ goal: Goal }>(`/api/goals/${encodeURIComponent(id)}/checkpoint`, idempotentMutation('POST', commitMessage ? { commitMessage } : {})); +export const requestGoalCheckpointInterval = async (id: string, minutes: number) => request<{ goal: Goal }>(`/api/goals/${encodeURIComponent(id)}/checkpoint-frequency`, idempotentMutation('PATCH', { minutes })); diff --git a/propr-ui/src/api/proprTypes.ts b/propr-ui/src/api/proprTypes.ts index 9f37c899e..078e5e223 100644 --- a/propr-ui/src/api/proprTypes.ts +++ b/propr-ui/src/api/proprTypes.ts @@ -83,6 +83,7 @@ export interface TaskAnalysisResponse { export interface QueueStats { active: number; + activeGoals?: number; activeJobs?: LiveQueueJob[]; waiting: number; completed: number; diff --git a/propr-ui/src/components/Layout.test.tsx b/propr-ui/src/components/Layout.test.tsx new file mode 100644 index 000000000..ca802e3a8 --- /dev/null +++ b/propr-ui/src/components/Layout.test.tsx @@ -0,0 +1,59 @@ +import { act, render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { QueueStatsUpdatePayload } from '@propr/shared'; +import Layout from './Layout'; + +let queueStatsCallback: ((payload: QueueStatsUpdatePayload) => void) | undefined; + +vi.mock('../api/proprApi', () => ({ logout: vi.fn() })); +vi.mock('../hooks/useDynamicFavicon', () => ({ useDynamicFavicon: vi.fn() })); +vi.mock('../hooks/useSystemReadiness', () => ({ + useSystemReadiness: () => ({ hasAgents: true, hasRepos: true, hasTasks: true }), +})); +vi.mock('./ui/useToast', () => ({ useToast: () => ({ addToast: vi.fn() }) })); +vi.mock('../contexts/DemoModeContext', () => ({ useDemoMode: () => ({ isDemoMode: false }) })); +vi.mock('../contexts/AuthContext', () => ({ + useCurrentUser: () => null, + userHasPermission: () => false, +})); +vi.mock('../contexts/NotificationCenterContext', () => ({ + useNotificationCenter: () => ({ unreadCount: null }), +})); +vi.mock('../contexts/useSocket', () => ({ + useSocket: () => ({ + isConnected: true, + subscribeToQueueStats: vi.fn(), + unsubscribeFromQueueStats: vi.fn(), + subscribeToIndexingUpdates: vi.fn(), + unsubscribeFromIndexingUpdates: vi.fn(), + onQueueStatsUpdate: (callback: (payload: QueueStatsUpdatePayload) => void) => { + queueStatsCallback = callback; + return vi.fn(); + }, + onIndexingUpdate: () => vi.fn(), + onDraftUpdate: () => vi.fn(), + }), +})); +vi.mock('./GlobalHeader', () => ({ default: () => null })); +vi.mock('./AgentTankSidebar', () => ({ default: () => null })); +vi.mock('./ConnectPlusBanner', () => ({ ConnectCapacityBanner: () => null })); + +describe('Layout sidebar counts', () => { + beforeEach(() => { + queueStatsCallback = undefined; + }); + + it('shows running goals separately from active tasks', () => { + render(
Page
); + + act(() => queueStatsCallback?.({ + eventType: 'queue:stats:update', + stats: { waiting: 0, active: 3, activeGoals: 2, completed: 0, failed: 0, delayed: 0, total: 3 }, + timestamp: '2026-09-03T23:30:00.000Z', + })); + + expect(within(screen.getByRole('link', { name: /Goals/ })).getByText('2')).toBeInTheDocument(); + expect(within(screen.getByRole('link', { name: /Tasks/ })).getByText('1')).toBeInTheDocument(); + }); +}); diff --git a/propr-ui/src/components/Layout.tsx b/propr-ui/src/components/Layout.tsx index 66c5b7998..775fc56a3 100644 --- a/propr-ui/src/components/Layout.tsx +++ b/propr-ui/src/components/Layout.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Link, useLocation } from 'react-router-dom'; -import { ScrollText, ListTodo, BookMarked, Bot, Cpu, ShieldCheck, Inbox } from 'lucide-react'; +import { ScrollText, ListTodo, BookMarked, Bot, Cpu, ShieldCheck, Inbox, Target } from 'lucide-react'; import { logout } from '../api/proprApi'; import { useDynamicFavicon } from '../hooks/useDynamicFavicon'; import { useSystemReadiness } from '../hooks/useSystemReadiness'; @@ -25,12 +25,23 @@ interface NavItem { icon: React.FC<{ className?: string }>; } +function WorkCountBadge({ name, taskCount, goalCount }: { name: string; taskCount: number; goalCount: number }) { + const count = name === 'Tasks' ? taskCount : name === 'Goals' ? goalCount : 0; + if (count <= 0) return null; + return ( + + {count} + + ); +} + const Layout: React.FC = ({ children }) => { const location = useLocation(); const { addToast } = useToast(); const { isDemoMode } = useDemoMode(); const { isConnected, subscribeToQueueStats, unsubscribeFromQueueStats, subscribeToIndexingUpdates, unsubscribeFromIndexingUpdates, onQueueStatsUpdate, onIndexingUpdate, onDraftUpdate } = useSocket(); - const [activeTaskCount, setActiveTaskCount] = useState(0); + const [activeQueueCount, setActiveQueueCount] = useState(0); + const [activeGoalCount, setActiveGoalCount] = useState(0); const [generatingPlansCount, setGeneratingPlansCount] = useState(0); const user = useCurrentUser(); const { unreadCount } = useNotificationCenter(); @@ -38,21 +49,21 @@ const Layout: React.FC = ({ children }) => { // Track repository indexing statuses for toast notifications const repoStatusesRef = useRef>(new Map()); - // Update favicon to show combined count of tasks + plans - // Note: activeTaskCount currently includes plans due to backend bug, which satisfies the requirement - useDynamicFavicon(activeTaskCount); + // Keep the favicon's existing aggregate active-work count. + useDynamicFavicon(activeQueueCount); // Track system readiness for proactive sidebar indicators const { hasAgents, hasRepos, hasTasks } = useSystemReadiness(); - // Calculate display task count for sidebar by subtracting plans (clamped to 0) - // This is a workaround for the backend including plan generation jobs in activeTaskCount - const displayTaskCount = Math.max(0, activeTaskCount - generatingPlansCount); + // The queue's active count aggregates task, plan, and goal jobs. Give each + // first-class work type its own sidebar count. + const displayTaskCount = Math.max(0, activeQueueCount - generatingPlansCount - activeGoalCount); const navigation: NavItem[] = [ { name: 'Dashboard', href: '/', icon: HomeIcon }, { name: 'Inbox', href: '/inbox', icon: Inbox }, { name: 'Plans', href: '/plans', icon: ScrollText }, + { name: 'Goals', href: '/goals', icon: Target }, { name: 'Tasks', href: '/tasks', icon: ListTodo }, { name: 'Repositories', href: '/repositories', icon: BookMarked }, ...(userHasPermission(user, 'instance.manage_agents') @@ -98,7 +109,9 @@ const Layout: React.FC = ({ children }) => { // Handle queue stats updates via WebSocket const handleQueueStatsUpdate = useCallback((payload: QueueStatsUpdatePayload) => { - setActiveTaskCount(payload.stats.active || 0); + const activeCount = payload.stats.active || 0; + setActiveQueueCount(activeCount); + setActiveGoalCount(Math.min(activeCount, Math.max(0, payload.stats.activeGoals || 0))); }, []); // Handle indexing updates via WebSocket for toast notifications @@ -206,11 +219,7 @@ const Layout: React.FC = ({ children }) => { > {item.name} - {item.name === 'Tasks' && displayTaskCount > 0 && ( - - {displayTaskCount} - - )} + {item.name === 'Inbox' && unreadCount !== null && unreadCount > 0 && ( {unreadCount > 99 ? '99+' : unreadCount} diff --git a/propr-ui/src/components/MobileBottomNavigation.tsx b/propr-ui/src/components/MobileBottomNavigation.tsx index c27321731..bca9b2c04 100644 --- a/propr-ui/src/components/MobileBottomNavigation.tsx +++ b/propr-ui/src/components/MobileBottomNavigation.tsx @@ -15,6 +15,7 @@ import { MoreHorizontal, ScrollText, Settings, + Target, ShieldCheck, X, } from 'lucide-react'; @@ -51,13 +52,14 @@ const getNavigationState = (pathname: string) => { more: pathname === '/' || pathMatches(pathname, '/plans') || (pathMatches(pathname, '/studio') && !newPlan) || pathMatches(pathname, '/ai-agents') || pathMatches(pathname, '/llm-logs') || - pathMatches(pathname, '/settings') || pathMatches(pathname, '/admin/members'), + pathMatches(pathname, '/settings') || pathMatches(pathname, '/admin/members') || pathMatches(pathname, '/goals'), }; }; const getMoreItems = (user: CurrentUser | null) => [ { label: 'Dashboard', to: '/', icon: Home }, { label: 'Plans', to: '/plans', icon: ScrollText }, + { label: 'Goals', to: '/goals', icon: Target }, ...(userHasPermission(user, 'instance.manage_agents') ? [{ label: 'Coding Agents', to: '/ai-agents', icon: Bot }] : []), diff --git a/propr-ui/src/components/TaskDetails/hooks.ts b/propr-ui/src/components/TaskDetails/hooks.ts index 6ac9ee352..9df415843 100644 --- a/propr-ui/src/components/TaskDetails/hooks.ts +++ b/propr-ui/src/components/TaskDetails/hooks.ts @@ -1,3 +1,4 @@ export { useTaskData } from './useTaskData'; +export { useTaskLiveData } from './useTaskLiveData'; export { usePromptData } from './usePromptData'; export { useLogFilesData } from './useLogFilesData'; diff --git a/propr-ui/src/components/TaskDetails/useTaskLiveData.ts b/propr-ui/src/components/TaskDetails/useTaskLiveData.ts new file mode 100644 index 000000000..9652b6f34 --- /dev/null +++ b/propr-ui/src/components/TaskDetails/useTaskLiveData.ts @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { getTaskLiveDetails } from '../../api/proprApi'; +import { useSocket } from '../../contexts/useSocket'; +import type { TaskLiveUpdatePayload } from '@propr/shared'; +import type { LiveDetails, LiveEvent } from './types'; +import { mergeIncrementalLiveDetails, normalizeLiveTodos } from './useTaskData'; + +export function useTaskLiveData(taskId: string | undefined, pollIntervalMs = 5_000) { + const [liveDetails, setLiveDetails] = useState({ events: [], todos: [], currentTask: null }); + const hasReceivedInitialDataRef = useRef(false); + const { + subscribeToTaskLive, + unsubscribeFromTaskLive, + onTaskLiveUpdate, + isConnected, + } = useSocket(); + + const refresh = useCallback(async () => { + if (!taskId) return null; + try { + const data = await getTaskLiveDetails(taskId) as LiveDetails; + setLiveDetails({ + events: data.events || [], + todos: data.todos || [], + currentTask: data.currentTask || null, + tokenUsage: data.tokenUsage || null, + }); + return data; + } catch { + return null; + } + }, [taskId]); + + useEffect(() => { + void refresh(); + if (!taskId || pollIntervalMs <= 0) return; + const timer = window.setInterval(() => { void refresh(); }, pollIntervalMs); + return () => window.clearInterval(timer); + }, [pollIntervalMs, refresh, taskId]); + + useEffect(() => { + if (!taskId || !isConnected) return; + subscribeToTaskLive(taskId); + const unsubscribe = onTaskLiveUpdate((payload: TaskLiveUpdatePayload) => { + if (payload.taskId !== taskId) return; + const newEvents: LiveEvent[] = payload.events || []; + if (!hasReceivedInitialDataRef.current) { + hasReceivedInitialDataRef.current = true; + setLiveDetails({ + events: newEvents, + todos: normalizeLiveTodos(payload.todos || []), + currentTask: payload.currentTask || null, + tokenUsage: payload.tokenUsage || null, + }); + } else { + setLiveDetails(previous => mergeIncrementalLiveDetails(previous, payload)); + } + }); + return () => { + unsubscribe(); + unsubscribeFromTaskLive(taskId); + hasReceivedInitialDataRef.current = false; + }; + }, [isConnected, onTaskLiveUpdate, subscribeToTaskLive, taskId, unsubscribeFromTaskLive]); + + return { liveDetails, refreshLiveDetails: refresh }; +} diff --git a/propr-ui/src/pages/GoalsPage.test.tsx b/propr-ui/src/pages/GoalsPage.test.tsx new file mode 100644 index 000000000..d8d098254 --- /dev/null +++ b/propr-ui/src/pages/GoalsPage.test.tsx @@ -0,0 +1,239 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import GoalsPage from './GoalsPage'; +import * as goalsApi from '../api/goals'; +import { getInstanceCatalog, getTaskLiveDetails } from '../api/proprApi'; + +vi.mock('../api/goals', () => ({ + getGoalCapabilities: vi.fn(), listGoals: vi.fn(), getGoal: vi.fn(), createGoal: vi.fn(), + pauseGoal: vi.fn(), resumeGoal: vi.fn(), cancelGoal: vi.fn(), deleteGoal: vi.fn(), requestGoalModel: vi.fn(), sendGoalInput: vi.fn(), + checkpointGoal: vi.fn(), requestGoalCheckpointInterval: vi.fn(), +})); +vi.mock('../api/proprApi', () => ({ getInstanceCatalog: vi.fn(), getTaskLiveDetails: vi.fn() })); +const socket = vi.hoisted(() => ({ + isConnected: false as boolean, subscribeToTask: vi.fn(), unsubscribeFromTask: vi.fn(), + subscribeToTaskLive: vi.fn(), unsubscribeFromTaskLive: vi.fn(), + onTaskUpdate: vi.fn((handler?: (payload: never) => void) => { void handler; return vi.fn(); }), + onTaskLiveUpdate: vi.fn((handler?: (payload: never) => void) => { void handler; return vi.fn(); }), +})); +vi.mock('../contexts/useSocket', () => ({ useSocket: () => socket })); + +const capability = { + agentId: 'agent-1', agentAlias: 'codex', agentType: 'codex', goalCapable: true, + lifecycle: { launch: 'native-goal', resume: 'native-goal', runningInput: 'live-steer' } as const, + controls: { liveInput: true, inputAtBoundary: true, modelAtBoundary: true, pauseAtBoundary: true }, + models: ['gpt-5.6-sol', 'gpt-5.6-luna'], defaultModel: 'gpt-5.6-sol', +}; +const goal: goalsApi.Goal = { + id: 'goal-1', owner: 'owner', repository: 'acme/web', objective: 'Ship the dashboard', + launchStrategy: 'orchestrate', initialPrompt: '/goal Ship the dashboard\n\nLaunch strategy — Agent orchestrates through ProPR', + baseBranch: null, branchName: 'goal/dashboard', worktreePath: '/tmp/worktree', + agent: { id: 'agent-1', alias: 'codex', type: 'codex' }, requestedModel: 'gpt-5.6-sol', effectiveModel: 'gpt-5.6-sol', + maxParallelTasks: 3, ultrafix: true, desiredState: 'running', resultState: null, + control: { requestGeneration: 0, acknowledgedGeneration: 0, pending: false }, + failureReason: null, pausePending: false, + taskId: 'goal-task-1', sessionId: 'thread-1', conversationId: null, finalPr: null, artifacts: [], + checkpoint: null, + artifactStats: { issues: 1, openIssues: 1, pullRequests: 1, openPullRequests: 1 }, + liveSummary: { currentTask: 'Implement API', todos: [{ id: 'todo-1', content: 'Implement API', status: 'in_progress' },], tokenUsage: { input_tokens: 10, output_tokens: 5 }, nativeGoal: null }, + taskState: 'claude_execution', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + startedAt: new Date().toISOString(), pausedAt: null, completedAt: null, elapsedMs: 1000, activeMs: 1000, pausedMs: 0, +}; + +describe('GoalsPage', () => { + beforeEach(() => { + vi.clearAllMocks(); + socket.isConnected = false; + socket.onTaskUpdate.mockImplementation(() => vi.fn()); + socket.onTaskLiveUpdate.mockImplementation(() => vi.fn()); + vi.mocked(goalsApi.getGoalCapabilities).mockResolvedValue({ agents: [capability] }); + vi.mocked(getInstanceCatalog).mockResolvedValue({ agents: [], repositories: [{ name: 'acme/web', enabled: true }] }); + vi.mocked(goalsApi.listGoals).mockResolvedValue({ goals: [] }); + vi.mocked(goalsApi.getGoal).mockResolvedValue({ goal }); + vi.mocked(getTaskLiveDetails).mockResolvedValue({ events: [], todos: [{ id: 'todo-1', content: 'Implement API', status: 'in_progress' }], currentTask: 'Implement API', tokenUsage: { input_tokens: 10, output_tokens: 5 } }); + vi.mocked(goalsApi.pauseGoal).mockResolvedValue({ goal: { ...goal, desiredState: 'paused', pausedAt: new Date().toISOString() } }); + vi.mocked(goalsApi.sendGoalInput).mockResolvedValue({ goal }); + vi.mocked(goalsApi.cancelGoal).mockResolvedValue({ goal: { ...goal, desiredState: 'cancelled', resultState: null } }); + vi.mocked(goalsApi.deleteGoal).mockResolvedValue(); + vi.mocked(goalsApi.requestGoalModel).mockResolvedValue({ goal: { ...goal, requestedModel: 'gpt-5.6-luna' } }); + vi.mocked(goalsApi.checkpointGoal).mockResolvedValue({ goal }); + vi.mocked(goalsApi.requestGoalCheckpointInterval).mockResolvedValue({ goal }); + }); + + it('creates exactly one native goal from repository, agent, model and objective', async () => { + vi.mocked(goalsApi.createGoal).mockResolvedValue({ goal }); + render(} />Goal detail} />); + await screen.findByRole('option', { name: 'Codex' }); + expect(screen.getByRole('button', { name: /acme.*web/ })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'GPT-5.6 Sol' })).toHaveValue('gpt-5.6-sol'); + fireEvent.change(screen.getByLabelText('Objective'), { target: { value: 'Ship the dashboard' } }); + fireEvent.click(screen.getByLabelText('Agent orchestrates through ProPR')); + fireEvent.click(screen.getByRole('button', { name: 'Start goal' })); + await waitFor(() => expect(goalsApi.createGoal).toHaveBeenCalledWith(expect.objectContaining({ repository: 'acme/web', agentId: 'agent-1', model: 'gpt-5.6-sol', objective: 'Ship the dashboard', launchStrategy: 'orchestrate' }))); + expect(await screen.findByText('Goal detail')).toBeInTheDocument(); + }); + + it('configures worker checkpoints only for direct goals', async () => { + vi.mocked(goalsApi.createGoal).mockResolvedValue({ goal: { ...goal, launchStrategy: 'direct' } }); + render(} />Goal detail} />); + await screen.findByRole('option', { name: 'Codex' }); + fireEvent.change(screen.getByLabelText('Objective'), { target: { value: 'Ship the dashboard' } }); + const checkpointSlider = screen.getByRole('slider', { name: 'Checkpoint frequency' }); + expect(checkpointSlider).toHaveAttribute('aria-valuetext', '15 minutes'); + const checkpointOptions = screen.getByLabelText('Checkpoint frequency options'); + for (const minutes of [5, 10, 15, 30, 60, 120]) { + expect(within(checkpointOptions).getByText(String(minutes))).toBeInTheDocument(); + } + fireEvent.change(checkpointSlider, { target: { value: '3' } }); + expect(checkpointSlider).toHaveAttribute('aria-valuetext', '30 minutes'); + fireEvent.click(screen.getByRole('button', { name: 'Start goal' })); + await waitFor(() => expect(goalsApi.createGoal).toHaveBeenCalledWith(expect.objectContaining({ + launchStrategy: 'direct', checkpointIntervalMinutes: 30, + }))); + }); + + it('hides unsupported runtime diagnostics when at least one agent supports goals', async () => { + vi.mocked(goalsApi.getGoalCapabilities).mockResolvedValue({ agents: [ + capability, + { ...capability, agentId: 'agent-2', agentAlias: 'opencode', agentType: 'opencode', goalCapable: false, reason: 'OpenCode does not support goal sessions' }, + ] }); + render(} />); + + await screen.findByRole('option', { name: 'Codex' }); + expect(screen.getByRole('option', { name: 'Opencode — unsupported' })).toBeDisabled(); + expect(screen.queryByText('OpenCode does not support goal sessions')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Recheck runtimes' })).not.toBeInTheDocument(); + }); + + it('shows each unsupported provider reason, gates creation, and can recheck runtimes', async () => { + vi.mocked(goalsApi.getGoalCapabilities) + .mockResolvedValueOnce({ agents: [ + { ...capability, goalCapable: false, reason: 'Codex schema lacks thread/goal/clear' }, + { ...capability, agentId: 'agent-2', agentAlias: 'antigravity', agentType: 'antigravity', goalCapable: false, reason: 'Antigravity lacks --conversation' }, + ] }) + .mockResolvedValueOnce({ agents: [capability] }); + render(} />); + expect(await screen.findByText('No configured coding-agent runtime currently supports goals.')).toBeInTheDocument(); + expect(screen.getByText('Codex schema lacks thread/goal/clear')).toBeInTheDocument(); + expect(screen.getByText('Antigravity lacks --conversation')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Start goal' })).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Recheck runtimes' })); + await waitFor(() => expect(goalsApi.getGoalCapabilities).toHaveBeenCalledWith(true)); + await waitFor(() => expect(screen.queryByText('Codex schema lacks thread/goal/clear')).not.toBeInTheDocument()); + fireEvent.change(screen.getByLabelText('Objective'), { target: { value: 'Ship the dashboard' } }); + expect(screen.getByRole('button', { name: 'Start goal' })).toBeEnabled(); + }); + + it('projects native checklist, time, token, and repository artifact stats in the goal list', async () => { + vi.mocked(goalsApi.listGoals).mockResolvedValue({ goals: [{ + ...goal, + liveSummary: { + ...goal.liveSummary, + nativeGoal: { objective: goal.objective, status: 'active', tokenBudget: 1000, tokensUsed: 330, timeUsedSeconds: 42 }, + }, + }] }); + render(} />); + expect(await screen.findByText('330 tokens')).toBeInTheDocument(); + expect(screen.getByText('42s active')).toBeInTheDocument(); + expect(screen.getByText('1/1 open issues')).toBeInTheDocument(); + expect(screen.getByText('1/1 open PRs')).toBeInTheDocument(); + expect(screen.getByText('Implement API')).toBeInTheDocument(); + expect(screen.getByText(goal.objective)).toHaveClass('line-clamp-2'); + expect(screen.getAllByText('Codex')).toHaveLength(2); + expect(screen.getAllByText('GPT-5.6 Sol')).toHaveLength(2); + }); + + it('renders existing task live details and sends canned status input through the same session', async () => { + render(} />); + expect((await screen.findAllByText('Implement API')).length).toBeGreaterThan(0); + expect(screen.getByText('15')).toBeInTheDocument(); + expect(screen.getByText('Agent orchestrates through ProPR')).toBeInTheDocument(); + expect(screen.getByText(/\/goal Ship the dashboard/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: "What's done?" })); + await waitFor(() => expect(goalsApi.sendGoalInput).toHaveBeenCalledWith('goal-1', { canned: 'done' })); + expect(goalsApi.pauseGoal).not.toHaveBeenCalled(); + }); + + it('accepts a correction while the initial provider identity is still pending', async () => { + vi.mocked(goalsApi.getGoal).mockResolvedValue({ goal: { ...goal, sessionId: null } }); + render(} />); + const correction = await screen.findByLabelText('Correction or follow-up'); + fireEvent.change(correction, { target: { value: 'Use the existing API shape.' } }); + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + await waitFor(() => expect(goalsApi.sendGoalInput).toHaveBeenCalledWith('goal-1', { message: 'Use the existing API shape.' })); + }); + + it('requests the next model separately from the effective model and exposes cancellation', async () => { + render(} />); + await screen.findByRole('button', { name: 'Cancel' }); + fireEvent.change(screen.getByLabelText('Model for next continuation'), { target: { value: 'gpt-5.6-luna' } }); + await waitFor(() => expect(goalsApi.requestGoalModel).toHaveBeenCalledWith('goal-1', 'gpt-5.6-luna')); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(goalsApi.cancelGoal).toHaveBeenCalledWith('goal-1')); + expect(await screen.findByText('cancelling')).toBeInTheDocument(); + expect(screen.getByText(/Cancelling at the provider boundary/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Correction or follow-up')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Model for next continuation')).not.toBeInTheDocument(); + }); + + it('confirms deletion and returns to the goals list after the server stops and removes the goal', async () => { + vi.spyOn(window, 'confirm').mockReturnValueOnce(true); + render(} />Goals list} />); + fireEvent.click(await screen.findByRole('button', { name: 'Delete goal' })); + await waitFor(() => expect(goalsApi.deleteGoal).toHaveBeenCalledWith('goal-1')); + expect(await screen.findByText('Goals list')).toBeInTheDocument(); + expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('stopped first')); + }); + + it('requests worker-owned checkpoint commits and adjusts their safe-boundary frequency', async () => { + const directGoal: goalsApi.Goal = { + ...goal, + launchStrategy: 'direct', + finalPr: { number: 42, url: 'https://github.com/acme/web/pull/42' }, + checkpoint: { + intervalMinutes: 15, count: 2, lastAt: new Date().toISOString(), lastCommitSha: 'abc123', + error: null, pending: false, latest: null, + }, + }; + vi.mocked(goalsApi.getGoal).mockResolvedValue({ goal: directGoal }); + vi.mocked(goalsApi.checkpointGoal).mockResolvedValue({ goal: { ...directGoal, checkpoint: { ...directGoal.checkpoint!, pending: true } } }); + vi.mocked(goalsApi.requestGoalCheckpointInterval).mockResolvedValue({ goal: { ...directGoal, checkpoint: { ...directGoal.checkpoint!, intervalMinutes: 30 } } }); + render(} />); + fireEvent.click(await screen.findByRole('button', { name: 'Checkpoint now' })); + await waitFor(() => expect(goalsApi.checkpointGoal).toHaveBeenCalledWith('goal-1')); + expect(await screen.findByRole('button', { name: 'Checkpoint pending' })).toBeDisabled(); + fireEvent.change(screen.getByLabelText('Checkpoint frequency'), { target: { value: '30' } }); + await waitFor(() => expect(goalsApi.requestGoalCheckpointInterval).toHaveBeenCalledWith('goal-1', 30)); + expect(screen.getByRole('link', { name: /Open draft PR/ })).toHaveAttribute('href', directGoal.finalPr!.url); + }); + + it('re-subscribes after reconnect and merges incremental native output', async () => { + socket.isConnected = true; + let liveHandler: ((payload: never) => void) | undefined; + socket.onTaskLiveUpdate.mockImplementation(handler => { + liveHandler = handler; + return vi.fn(); + }); + const page = () => } />; + const view = render(page()); + await waitFor(() => expect(socket.subscribeToTaskLive).toHaveBeenCalledWith('goal-task-1')); + act(() => liveHandler?.({ + taskId: 'goal-task-1', events: [{ id: 'initial', type: 'thought', content: 'Initial native output' }], + todos: [], currentTask: 'Implementing', tokenUsage: null, + } as never)); + act(() => liveHandler?.({ + taskId: 'goal-task-1', events: [{ id: 'next', type: 'thought', content: 'Incremental update' }], + todos: [], currentTask: 'Testing', tokenUsage: null, + } as never)); + expect(await screen.findByText('Initial native output')).toBeInTheDocument(); + expect(await screen.findByText('Incremental update')).toBeInTheDocument(); + + socket.isConnected = false; + view.rerender(page()); + socket.isConnected = true; + view.rerender(page()); + await waitFor(() => expect(socket.subscribeToTaskLive).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/propr-ui/src/pages/GoalsPage.tsx b/propr-ui/src/pages/GoalsPage.tsx new file mode 100644 index 000000000..a49f4e303 --- /dev/null +++ b/propr-ui/src/pages/GoalsPage.tsx @@ -0,0 +1,288 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { + Activity, CheckCircle2, Circle, CircleDot, CirclePause, CirclePlay, CircleStop, Clock3, + Coins, ExternalLink, GitCommit, GitPullRequest, Github, ListTodo, LoaderCircle, Plus, Send, Trash2, +} from 'lucide-react'; +import { getInstanceCatalog } from '../api/proprApi'; +import type { InstanceCatalogRepository } from '../api/proprTypes'; +import { + cancelGoal, checkpointGoal, createGoal, deleteGoal, getGoal, getGoalCapabilities, listGoals, pauseGoal, + requestGoalCheckpointInterval, requestGoalModel, resumeGoal, sendGoalInput, + type Goal, type GoalCapability, type GoalLaunchStrategy, +} from '../api/goals'; +import { useTaskLiveData } from '../components/TaskDetails/useTaskLiveData'; +import TodoList from '../components/TaskDetails/TodoList'; +import RealTimeStats from '../components/TaskDetails/RealTimeStats'; +import ExecutionEventLog from '../components/TaskDetails/ExecutionEventLog'; +import { RepositorySelector, type RepoOption } from '../components/RepositorySelector'; +import { ProviderLogo } from '../components/ui/ProviderLogo'; +import { useDocumentTitle } from '../hooks/useDocumentTitle'; +import { formatAgentLabel } from '../utils/agentStatus'; +import { getModelDisplayName } from '../utils/modelDisplay'; + +const buttonClass = 'inline-flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-50'; +const checkpointIntervalOptions = [5, 10, 15, 30, 60, 120]; +const duration = (milliseconds: number) => { + const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return hours ? `${hours}h ${minutes}m` : minutes ? `${minutes}m ${seconds}s` : `${seconds}s`; +}; +const tokenTotal = (usage: { input_tokens?: number | null; output_tokens?: number | null; cache_creation_input_tokens?: number | null; cache_read_input_tokens?: number | null } | null) => usage + ? (usage.input_tokens || 0) + (usage.output_tokens || 0) + + (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0) + : 0; + +const capabilityAgentLabel = (agent: GoalCapability, agents: GoalCapability[]) => formatAgentLabel( + { type: agent.agentType, alias: agent.agentAlias }, + agents.map(candidate => ({ type: candidate.agentType, alias: candidate.agentAlias })), +); + +function GoalState({ goal }: { goal: Goal }) { + const state = goal.resultState || (goal.desiredState === 'cancelled' ? 'cancelling' : goal.desiredState); + const color = state === 'completed' ? 'bg-green-100 text-green-800' : state === 'failed' || state === 'cancelled' ? 'bg-red-100 text-red-800' : state === 'paused' || state === 'cancelling' ? 'bg-amber-100 text-amber-800' : 'bg-blue-100 text-blue-800'; + return {state}; +} + +function CreateGoalForm({ onCreated }: { onCreated: (goal: Goal) => void }) { + const [repositories, setRepositories] = useState([]); + const [agents, setAgents] = useState([]); + const [repository, setRepository] = useState(''); + const [agentId, setAgentId] = useState(''); + const [model, setModel] = useState(''); + const [objective, setObjective] = useState(''); + const [launchStrategy, setLaunchStrategy] = useState('direct'); + const [parallelism, setParallelism] = useState(''); + const [ultrafix, setUltrafix] = useState(false); + const [checkpointInterval, setCheckpointInterval] = useState(15); + const [submitting, setSubmitting] = useState(false); + const [rechecking, setRechecking] = useState(false); + const [error, setError] = useState(null); + const selectedAgent = agents.find(agent => agent.agentId === agentId); + const unsupportedAgents = agents.filter(agent => !agent.goalCapable); + const showRuntimeDiagnostics = agents.length > 0 && unsupportedAgents.length === agents.length; + const repositoryOptions = useMemo(() => repositories.map(repo => ({ + name: repo.name, + enabled: repo.enabled, + ...(repo.alias ? { displayName: repo.alias } : {}), + ...(repo.baseBranch ? { baseBranch: repo.baseBranch } : {}), + })), [repositories]); + + const applyCapabilities = useCallback((capabilities: GoalCapability[]) => { + setAgents(capabilities); + setAgentId(current => capabilities.some(agent => agent.agentId === current && agent.goalCapable) + ? current + : capabilities.find(agent => agent.goalCapable)?.agentId || ''); + }, []); + + useEffect(() => { + Promise.all([getInstanceCatalog(), getGoalCapabilities()]).then(([catalog, capabilityData]) => { + setRepositories(catalog.repositories); + applyCapabilities(capabilityData.agents); + setRepository(catalog.repositories[0]?.name || ''); + }).catch(err => setError((err as Error).message)); + }, [applyCapabilities]); + + useEffect(() => { + if (selectedAgent && !selectedAgent.models.includes(model)) setModel(selectedAgent.defaultModel || selectedAgent.models[0] || ''); + }, [model, selectedAgent]); + + const recheckCapabilities = async () => { + setRechecking(true); + setError(null); + try { + applyCapabilities((await getGoalCapabilities(true)).agents); + } catch (err) { + setError((err as Error).message); + } finally { + setRechecking(false); + } + }; + + const submit = async (event: React.FormEvent) => { + event.preventDefault(); + setSubmitting(true); + setError(null); + try { + const result = await createGoal({ + repository, agentId, model, objective, launchStrategy, + ...(parallelism ? { maxParallelTasks: Number(parallelism) } : {}), + ...(launchStrategy === 'direct' ? { checkpointIntervalMinutes: checkpointInterval } : {}), + ultrafix, + }); + onCreated(result.goal); + } catch (err) { setError((err as Error).message); } + finally { setSubmitting(false); } + }; + + return ( +
+

Start a goal

+ {error &&

{error}

} + {showRuntimeDiagnostics &&
+

No configured coding-agent runtime currently supports goals.

+
    + {unsupportedAgents.map(agent =>
  • {agent.agentAlias}: {agent.reason || 'Required goal/session transport is unavailable'}
  • )} +
+ +
} +
+
Repository + +
+ + + +
+
+ Goal launch strategy +
+ + +
+
+ {launchStrategy === 'direct' &&
+
+ + {checkpointInterval} minutes +
+ setCheckpointInterval(checkpointIntervalOptions[Number(event.target.value)])} + className="mt-3 h-2 w-full cursor-pointer accent-primary-600" + /> +
+ {checkpointIntervalOptions.map(minutes => {minutes})} +
+

Automatic commits run at safe provider-turn boundaries.

+
} +