Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ GITHUB_REPOS_TO_MONITOR=owner/repo1,owner/repo2
# ProPR pulls events from the GitHub API on this interval using any usable
# GitHub auth (relay or your own App). POLLING_INTERVAL_MS is the poll period in
# milliseconds (default: 60000).
# NOTE: `/split` requires issue_comment event delivery and is not accepted in
# polling mode. Use routing_websocket or direct_webhook for `/split` commands.
POLLING_INTERVAL_MS=60000

# Config Repository (for dynamic repository management)
Expand All @@ -142,6 +144,12 @@ GITHUB_ISSUE_QUEUE_NAME=github-issue-processor
WORKER_CONCURRENCY=2
COMMENT_BATCH_DELAY_MS=3000

# Webhook `/split` commands are recognized, durably recorded, and receive a
# disabled response even while this is false. Set true only when a deployed
# split-operation worker is ready to consume the queued operations it enables.
# `/split` remains unavailable to polling intake in either state.
# PR_SPLIT_EXECUTION_ENABLED=false

# Summarization fallback/cooldown controls
# Promote the configured summarization fallback to primary after this many
# primary quota failures for the same agent/model. Defaults to 3.
Expand Down Expand Up @@ -322,6 +330,8 @@ PROPR_DEMO_MODE=false
# --- SQLite Database ---
# Path to SQLite database file (will be created if it doesn't exist)
DB_FILENAME=./data/propr.sqlite
# How long each SQLite connection waits for another process's writer lock.
# SQLITE_BUSY_TIMEOUT_MS=30000

# --- PR Preview Environment ---
# docker-compose.yml and scripts/deploy-pr.sh consume these local names.
Expand Down
4 changes: 4 additions & 0 deletions docs/docs/operations/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ The backend authenticates to GitHub in one of three modes — `demo`, `relay`, o
| `LOG_LEVEL` | `info` | Log verbosity across services. | Optional. |
| `NODE_ENV` | `development` | Node environment; use `production` on servers. | Optional. |
| `DB_FILENAME` | `./data/propr.sqlite` | Path to the SQLite database file (created if it doesn't exist). | Optional. |
| `SQLITE_BUSY_TIMEOUT_MS` | `30000` | Maximum time each SQLite connection waits for a writer in another process before reporting contention. | Optional. |

## Event Intake

Expand All @@ -46,6 +47,7 @@ How ProPR receives GitHub events, plus what it watches for once they arrive. All
| `PROPR_ROUTING_WS_PONG_TIMEOUT_MS` | `30000` (30 seconds) | Maximum wait for a transport pong before the stale socket is terminated and reconnected. | Optional. |
| `POLLING_INTERVAL_MS` | `60000` | Poll period when pulling events from the GitHub API. | Polling mode only. |
| `GH_WEBHOOK_SECRET` | Unset | Shared secret GitHub signs webhook deliveries with. | Direct webhook mode. |
| `PR_SPLIT_EXECUTION_ENABLED` | `false` | Allows eligible `/split` commands to create queued operations. Even when false, webhook intake recognizes and stores the command and posts a disabled response. Accepted true values are `1` and case-insensitive `true`. | Only with `routing_websocket` or `direct_webhook` intake and a deployed split-operation worker. |
| `GITHUB_REPOS_TO_MONITOR` | Placeholder (`owner/repo1,owner/repo2`) | Comma-separated repositories the daemon watches. | Always. |
| `CONFIG_REPO` | Example config repo URL | Git repository for dynamic repository management; when set, processing labels and repo config load from it. | Optional. |
| `PRIMARY_PROCESSING_LABELS` | Shipped `AI,propr` / code falls back to `AI` | Issue labels that trigger processing. | Optional. |
Expand All @@ -56,6 +58,8 @@ How ProPR receives GitHub events, plus what it watches for once they arrive. All
| `PR_FOLLOWUP_TRIGGER_KEYWORDS` | `!propr` | Keywords in PR comments that trigger follow-up work. See [PR Follow-up](../features/pr-followup.md). | Optional. |
| `LABEL_APPLIER_TIMELINE_MAX_PAGES` | `5` | With a whitelist set, polling resolves who applied the trigger label from the issue timeline (page 1 + the most recent N pages). Raise it if long-lived issues are skipped with "Could not determine label applier". | Optional. |

> **`/split` intake limitation:** `/split` commands require an `issue_comment` event and are recognized only in `routing_websocket` or `direct_webhook` mode. Polling intentionally ignores them and does not post a response. With execution disabled (the default), webhook commands still create durable receipts and a disabled comment but never create an operation. Enable execution only alongside a deployed split-operation worker. Intake durably suppresses responses after five commands from the same repository/user identity in ten minutes.

## Agents & Timeouts

Unified image selection, per-agent credential paths, and execution limits. Coding-agent task executions default to 24 hours; analysis calls keep their separate, shorter timeouts.
Expand Down
2 changes: 2 additions & 0 deletions docs/docs/operations/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ in [ProPR Connect](./propr-connect.md).

**Polling** (`GITHUB_EVENT_INTAKE_MODE=polling`) suits installs that prefer to pull rather than maintain a streaming connection; it needs no inbound endpoint but adds latency and consumes the API budget continuously. The interval is `POLLING_INTERVAL_MS` (default `60000`).

> **Command limitation:** polling does not accept `/split` PR comments and does not post a refusal. `/split` requires `issue_comment` event delivery, so use `routing_websocket` or `direct_webhook` for that command.

**Direct webhook** (`GITHUB_EVENT_INTAKE_MODE=direct_webhook`) is for running your own GitHub App with GitHub delivering events to a public endpoint:

```bash
Expand Down
14 changes: 7 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test": "node --test",
"test:unit": "NODE_ENV=test npx tsx --test test/minimal.test.ts test/modelName.test.ts test/daemonEventIntake.test.ts test/githubEventIntakeMode.test.ts test/intakeModePrerequisites.test.ts test/validateRoutingUrl.test.ts test/routingWebSocketProtocol.test.ts test/routingWebSocketIntakeService.test.ts test/routingStatusPublisher.test.ts packages/api/test/statusRoutes.test.ts packages/api/test/agentRuntimeRoutes.test.ts packages/api/test/instanceAuthorization.test.ts packages/api/test/routeAuthorization.test.ts",
"test:unit": "NODE_ENV=test npx tsx --test test/minimal.test.ts test/modelName.test.ts test/daemonEventIntake.test.ts test/githubEventIntakeMode.test.ts test/intakeModePrerequisites.test.ts test/validateRoutingUrl.test.ts test/routingWebSocketProtocol.test.ts test/routingWebSocketIntakeService.test.ts test/routingStatusPublisher.test.ts test/prSplit/commandAuthorization.test.ts test/prSplit/operationStore.test.ts test/prSplit/intake.test.ts test/prSplit/interception.test.ts packages/api/test/statusRoutes.test.ts packages/api/test/agentRuntimeRoutes.test.ts packages/api/test/instanceAuthorization.test.ts packages/api/test/routeAuthorization.test.ts",
"test:e2e": "npx tsx --test test/e2e.test.ts",
"test:docker": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test test/*.test.ts",
"test:docker": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test test/*.test.ts test/prSplit/*.test.ts",
"test:docker:single": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker npx tsx --test",
"test:docker:quick": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker sh -c 'npx tsx --test test/*.test.ts --test-exclude=\"**/agentRegistry.test.ts\"'",
"test:docker:timeout": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker timeout 30 npx tsx --test test/*.test.ts",
"test:docker:quick": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker sh -c 'npx tsx --test test/*.test.ts test/prSplit/*.test.ts --test-exclude=\"**/agentRegistry.test.ts\"'",
"test:docker:timeout": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker timeout 30 npx tsx --test test/*.test.ts test/prSplit/*.test.ts",
"test:docker:clean": "docker ps --filter 'name=propr-worker-run' -q | xargs -r docker stop; npm run test:docker",
"test:docker:verbose": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker sh -c 'for f in test/*.test.ts; do echo \"=== Testing: $f ===\"; npx tsx --test \"$f\" || echo \"FAILED: $f\"; done'",
"test:docker:find-hanging": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker sh -c 'for f in test/*.test.ts; do echo \"=== Testing: $f ===\"; timeout 5 npx tsx --test \"$f\" > /dev/null 2>&1 && echo \"✓ PASSED\" || echo \"✗ FAILED or TIMEOUT\"; done'",
"test:docker:stable": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker sh -c 'for f in test/*.test.ts; do [[ \"$f\" == *\"agentRegistry\"* ]] && continue; echo \"=== Testing: $f ===\"; timeout 10 npx tsx --test \"$f\" > /dev/null 2>&1 && echo \"✓ PASSED\" || echo \"✗ FAILED or TIMEOUT\"; done'",
"test:docker:verbose": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker sh -c 'for f in test/*.test.ts test/prSplit/*.test.ts; do echo \"=== Testing: $f ===\"; npx tsx --test \"$f\" || echo \"FAILED: $f\"; done'",
"test:docker:find-hanging": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker sh -c 'for f in test/*.test.ts test/prSplit/*.test.ts; do echo \"=== Testing: $f ===\"; timeout 5 npx tsx --test \"$f\" > /dev/null 2>&1 && echo \"✓ PASSED\" || echo \"✗ FAILED or TIMEOUT\"; done'",
"test:docker:stable": "docker-compose run --rm -e REDIS_HOST=redis -e NODE_ENV=test worker sh -c 'for f in test/*.test.ts test/prSplit/*.test.ts; do [[ \"$f\" == *\"agentRegistry\"* ]] && continue; echo \"=== Testing: $f ===\"; timeout 10 npx tsx --test \"$f\" > /dev/null 2>&1 && echo \"✓ PASSED\" || echo \"✗ FAILED or TIMEOUT\"; done'",
"start": "node dist/src/index.js",
"dev": "tsx watch src/index.ts",
"daemon": "node dist/src/daemon.js",
Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/agents/impl/OpenCodeAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import path from 'path';
import { execSync } from 'child_process';
import logger from '../../utils/logger.js';
import { Agent, AgentConfig, AgentTaskOptions, AgentExecutionResult, AnalysisResult, AnalyzeOptions } from '../types.js';
import { executeDockerCommand } from '../../claude/docker/dockerExecutor.js';
import { executeDockerCommand, type ExecutionResult } from '../../claude/docker/dockerExecutor.js';
import { verifyWorktreeStructure, verifyWorktreePostExecution, setWorktreeOwnership, UsageLimitError } from '../../claude/claudeHelpers.js';
import { resolveConfigPath } from '../../config/configManager.js';
import { persistLlmLog, createLlmLogFromAnalysis, createLlmLogFromAgentExecution, buildTaskWorkRef, buildAnalysisWorkRef, formatUsageMetrics } from '../../utils/llmLogger.js';
Expand All @@ -18,6 +18,14 @@ export { UsageLimitError };

const DEFAULT_OPENCODE_ANALYSIS_ROOT = '/tmp/git-processor/opencode-analysis';

function resolveOpenCodeExecutionOutcome(
result: ExecutionResult,
parsedOutput: ParsedOpenCodeOutput
): Pick<AgentExecutionResult, 'success' | 'terminationReason'> {
const terminationReason = resolveAgentTerminationReason({ timedOut: result.timedOut, error: parsedOutput.error || result.stderr });
return { success: result.exitCode === 0 && !parsedOutput.error && !terminationReason, terminationReason };
}

export class OpenCodeAgent implements Agent {
readonly config: AgentConfig;
private readonly timeoutMs: number;
Expand Down Expand Up @@ -69,8 +77,7 @@ export class OpenCodeAgent implements Agent {
const executionTime = Date.now() - startTime;
const parsedOutput = this.parseOpenCodeJsonl(result.stdout);
const modelUsed = parsedOutput.modelUsed || effectiveModel || 'unknown';
const terminationReason = resolveAgentTerminationReason({ timedOut: result.timedOut, error: parsedOutput.error || result.stderr });
const success = result.exitCode === 0 && !parsedOutput.error && !terminationReason;
const { success, terminationReason } = resolveOpenCodeExecutionOutcome(result, parsedOutput);
const errorText = success ? undefined : (parsedOutput.error || result.stderr || `OpenCode exited with code ${result.exitCode ?? 'unknown'}`);
const response: AgentExecutionResult = {
success,
Expand Down
13 changes: 10 additions & 3 deletions packages/core/src/agents/impl/VibeAgent.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'fs';
import logger from '../../utils/logger.js';
import { Agent, AgentConfig, AgentTaskOptions, AgentExecutionResult, AnalysisResult, AnalyzeOptions } from '../types.js';
import { executeDockerCommand } from '../../claude/docker/dockerExecutor.js';
import { executeDockerCommand, type ExecutionResult } from '../../claude/docker/dockerExecutor.js';
import { wrapDockerRunArgsWithRepoSetup } from '../../claude/docker/repoSetupWrapper.js';
import { verifyWorktreeStructure, verifyWorktreePostExecution, setWorktreeOwnership, UsageLimitError } from '../../claude/claudeHelpers.js';
import { resolveConfigPath, loadSettings } from '../../config/configManager.js';
Expand All @@ -21,6 +21,14 @@ export { getMistralApiKeyFromSettings, readLatestVibeSessionTokenUsage } from '.
const DEFAULT_VIBE_MAX_TURNS = 1000;
const CONTAINER_CONFIG_PATH = '/home/node/.vibe';

function resolveVibeExecutionOutcome(
result: ExecutionResult,
parsedOutput: ReturnType<typeof parseVibeOutput>
): Pick<AgentExecutionResult, 'success' | 'terminationReason'> {
const terminationReason = resolveAgentTerminationReason({ timedOut: result.timedOut, error: parsedOutput.error || result.stderr });
return { success: isSuccessfulVibeResult(result.exitCode, parsedOutput) && !terminationReason, terminationReason };
}

interface VibeDockerArgsParams {
worktreePath: string;
githubToken: string;
Expand Down Expand Up @@ -106,8 +114,7 @@ export class VibeAgent implements Agent {
const conversationLog = parseVibeConversationLog(result.stdout);
const tokenUsage = parsedOutput.tokenUsage || readLatestVibeSessionTokenUsage(runtimeHomePath);
const modelUsed = parsedOutput.model || effectiveModel || 'unknown';
const terminationReason = resolveAgentTerminationReason({ timedOut: result.timedOut, error: parsedOutput.error || result.stderr });
const success = isSuccessfulVibeResult(result.exitCode, parsedOutput) && !terminationReason;
const { success, terminationReason } = resolveVibeExecutionOutcome(result, parsedOutput);
const error = success ? undefined : buildVibeFailureMessage(result, parsedOutput);
if (parsedOutput.sessionId && onSessionId) onSessionId(parsedOutput.sessionId);

Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/claude/claudeHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,6 @@ export async function storePromptInRedis(options: StorePromptOptions): Promise<v

await redis.quit();
} catch (redisError) {
const error = redisError as Error;
logger.warn({ issueNumber: issueRef.number, error: error.message }, 'Failed to store execution prompt in Redis - continuing');
logger.warn({ issueNumber: issueRef.number, error: (redisError as Error).message }, 'Failed to store execution prompt in Redis - continuing');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Durable intake state for `/split` pull-request commands.
*
* Command receipts preserve the original intake disposition across webhook
* redeliveries. Partial unique indexes enforce semantic deduplication (while
* allowing failed requests to be retried) and the one-active-operation mutex
* for each source PR, keyed by GitHub's immutable repository ID.
*/
export async function up(knex) {
await knex.schema.createTable('pr_split_operations', (table) => {
table.text('id').primary();
table.bigInteger('repository_id').notNullable();
table.text('repository').notNullable();
table.integer('source_pr_number').notNullable();
table.text('base_ref').notNullable();
table.text('base_sha').notNullable();
table.text('head_sha').notNullable();
table.bigInteger('requester_id').notNullable();
table.text('requester').notNullable();
table.bigInteger('original_comment_id').notNullable();
table.text('instruction').notNullable().defaultTo('');
table.text('event_key').notNullable();
table.text('dedupe_key').notNullable();
table.text('status').notNullable().defaultTo('queued').checkIn([
'queued',
'running',
'completed',
'failed',
]);
table.text('error_message').nullable();
table.timestamp('started_at').nullable();
table.timestamp('heartbeat_at').nullable();
table.timestamp('lease_expires_at').nullable();
table.text('lease_token').nullable();
table.timestamp('finished_at').nullable();
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now());

table.index(['repository_id', 'source_pr_number']);
table.index('status');
table.index('original_comment_id');
table.index('dedupe_key');
});

await knex.raw(`
CREATE UNIQUE INDEX pr_split_operations_event_key_unique
ON pr_split_operations (event_key)
`);

await knex.raw(`
CREATE UNIQUE INDEX pr_split_operations_semantic_dedupe
ON pr_split_operations (dedupe_key)
WHERE status != 'failed'
`);

await knex.raw(`
CREATE UNIQUE INDEX pr_split_operations_one_active_per_pr
ON pr_split_operations (repository_id, source_pr_number)
WHERE status IN ('queued', 'running')
`);

await knex.schema.createTable('pr_split_command_receipts', (table) => {
table.text('event_key').primary();
table.bigInteger('repository_id').notNullable();
table.text('repository').notNullable();
table.integer('source_pr_number').notNullable();
table.bigInteger('requester_id').notNullable();
table.text('requester').notNullable();
table.bigInteger('original_comment_id').notNullable();
table.text('instruction').notNullable().defaultTo('');
table.text('outcome').notNullable().checkIn([
'processing',
'disabled',
'unauthorized',
'closed',
'invalid',
'rate_limited',
'queued',
'duplicate',
'active',
]);
table.text('duplicate_kind').nullable().checkIn(['event', 'semantic']);
table.text('operation_id')
.nullable()
.references('id')
.inTable('pr_split_operations')
.onDelete('SET NULL');
table.text('response_state').notNullable().defaultTo('pending').checkIn([
'pending',
'claimed',
'posted',
'suppressed',
]);
table.text('response_claim_token').nullable();
table.timestamp('response_claimed_at').nullable();
table.bigInteger('response_comment_id').nullable();
table.timestamp('response_posted_at').nullable();
table.timestamp('created_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('updated_at').notNullable().defaultTo(knex.fn.now());

table.unique(['repository_id', 'original_comment_id']);
table.index(['repository_id', 'source_pr_number']);
table.index(['repository_id', 'requester_id', 'created_at']);
table.index('operation_id');
table.index('response_state');
});
}

export async function down(knex) {
await knex.schema.dropTableIfExists('pr_split_command_receipts');
await knex.schema.dropTableIfExists('pr_split_operations');
}
Loading