patchrelay 0.75.1 → 0.75.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.75.1",
4
- "commit": "3f8fb3c35611",
5
- "builtAt": "2026-06-05T20:39:12.061Z"
3
+ "version": "0.75.3",
4
+ "commit": "0186011684f3",
5
+ "builtAt": "2026-06-09T00:09:44.822Z"
6
6
  }
package/dist/cli/args.js CHANGED
@@ -11,6 +11,7 @@ export const KNOWN_COMMANDS = new Set([
11
11
  "repos",
12
12
  "linear",
13
13
  "repo",
14
+ "maintenance",
14
15
  "dashboard",
15
16
  "dash",
16
17
  "d",
@@ -120,7 +121,9 @@ export function assertKnownFlags(parsed, command, allowedFlags) {
120
121
  ? "issue"
121
122
  : command === "service"
122
123
  ? "service"
123
- : "root");
124
+ : command === "maintenance"
125
+ ? "maintenance"
126
+ : "root");
124
127
  }
125
128
  export function parsePositiveIntegerFlag(value, flagName) {
126
129
  if (typeof value !== "string") {
@@ -0,0 +1,55 @@
1
+ import { runWebhookEventRetention } from "../../event-retention.js";
2
+ import { CliUsageError } from "../errors.js";
3
+ import { formatJson } from "../formatters/json.js";
4
+ import { writeOutput } from "../output.js";
5
+ export async function handleMaintenanceCommand(params) {
6
+ const [subcommand] = params.commandArgs;
7
+ if (subcommand !== "prune-events") {
8
+ throw new CliUsageError(`Unknown maintenance command: ${subcommand ?? ""}`.trim(), "maintenance");
9
+ }
10
+ const retentionDays = readPositiveIntegerFlag(params.parsed, "retention-days");
11
+ const batchSize = readPositiveIntegerFlag(params.parsed, "batch-size");
12
+ const archive = params.parsed.flags.get("archive") === true;
13
+ const discard = params.parsed.flags.get("discard") === true;
14
+ if (archive && discard) {
15
+ throw new CliUsageError("Use either --archive or --discard, not both", "maintenance");
16
+ }
17
+ const result = await runWebhookEventRetention({
18
+ db: params.data.db,
19
+ config: params.config,
20
+ options: {
21
+ dryRun: params.parsed.flags.get("dry-run") === true,
22
+ ...(retentionDays !== undefined ? { retentionDays } : {}),
23
+ ...(batchSize !== undefined ? { batchSize } : {}),
24
+ ...(archive ? { archiveOldEvents: true } : {}),
25
+ ...(discard ? { archiveOldEvents: false } : {}),
26
+ },
27
+ });
28
+ if (params.json) {
29
+ writeOutput(params.stdout, formatJson(result));
30
+ return 0;
31
+ }
32
+ writeOutput(params.stdout, [
33
+ `Cutoff: ${result.cutoffIso}`,
34
+ `Scanned: ${result.scanned}`,
35
+ `Archived: ${result.archived}`,
36
+ `Deleted: ${result.deleted}`,
37
+ `Remaining: ${result.remaining}`,
38
+ result.archiveFile ? `Archive: ${result.archiveFile}` : undefined,
39
+ result.dryRun ? "Dry run: yes" : undefined,
40
+ ].filter(Boolean).join("\n") + "\n");
41
+ return 0;
42
+ }
43
+ function readPositiveIntegerFlag(parsed, flag) {
44
+ const value = parsed.flags.get(flag);
45
+ if (value === undefined || value === false)
46
+ return undefined;
47
+ if (value === true) {
48
+ throw new CliUsageError(`--${flag} requires a value`, "maintenance");
49
+ }
50
+ const parsedValue = Number(value);
51
+ if (!Number.isInteger(parsedValue) || parsedValue <= 0) {
52
+ throw new CliUsageError(`--${flag} must be a positive integer`, "maintenance");
53
+ }
54
+ return parsedValue;
55
+ }
package/dist/cli/help.js CHANGED
@@ -45,6 +45,8 @@ export function rootHelpText() {
45
45
  " service status [--json] Show systemd state and local health",
46
46
  " service codex-status [--json] Show Codex account and usage snapshot from this service",
47
47
  " cluster [--json] Check service + workflow health across all tracked issues",
48
+ " maintenance prune-events [--dry-run] [--archive|--discard] [--retention-days <days>] [--json]",
49
+ " Prune or archive old processed webhook events",
48
50
  " service logs [--lines <count>] [--json] Show recent service logs",
49
51
  " serve Run the local PatchRelay service",
50
52
  "",
@@ -79,6 +81,7 @@ export function rootHelpText() {
79
81
  " patchrelay help issue",
80
82
  " patchrelay help service",
81
83
  " patchrelay help cluster",
84
+ " patchrelay help maintenance",
82
85
  ].join("\n");
83
86
  }
84
87
  export function linearHelpText() {
@@ -204,10 +207,35 @@ export function clusterHelpText() {
204
207
  " patchrelay cluster --json",
205
208
  ].join("\n");
206
209
  }
210
+ export function maintenanceHelpText() {
211
+ return [
212
+ "Usage:",
213
+ " patchrelay maintenance prune-events [options]",
214
+ "",
215
+ "Options:",
216
+ " --dry-run Count archiveable events without deleting",
217
+ " --archive Write old events to cold JSONL gzip storage before deleting",
218
+ " --discard Delete old events without archiving",
219
+ " --retention-days <days> Override configured retention window (default 7)",
220
+ " --batch-size <count> Override maintenance batch size",
221
+ " --json Emit structured JSON output",
222
+ " --help, -h Show this help",
223
+ "",
224
+ "Behavior:",
225
+ " Only processed webhook events older than the retention cutoff are touched.",
226
+ " Pending/unprocessed webhook events are never pruned.",
227
+ "",
228
+ "Examples:",
229
+ " patchrelay maintenance prune-events --dry-run",
230
+ " patchrelay maintenance prune-events --archive",
231
+ ].join("\n");
232
+ }
207
233
  export function helpTextFor(topic) {
208
234
  switch (topic) {
209
235
  case "cluster":
210
236
  return clusterHelpText();
237
+ case "maintenance":
238
+ return maintenanceHelpText();
211
239
  case "linear":
212
240
  return linearHelpText();
213
241
  case "repo":
package/dist/cli/index.js CHANGED
@@ -6,6 +6,7 @@ import { handleClusterCommand } from "./commands/cluster.js";
6
6
  import { handleLinearCommand } from "./commands/linear.js";
7
7
  import { handleSequenceCheckCommand } from "./commands/sequence-check.js";
8
8
  import { handleRepoCommand } from "./commands/repo.js";
9
+ import { handleMaintenanceCommand } from "./commands/maintenance.js";
9
10
  import { handleInitCommand, handleServiceCommand } from "./commands/setup.js";
10
11
  import { CliUsageError } from "./errors.js";
11
12
  import { formatJson } from "./formatters/json.js";
@@ -25,6 +26,7 @@ function getCommandConfigProfile(command) {
25
26
  case "cluster":
26
27
  case "repo":
27
28
  case "issue":
29
+ case "maintenance":
28
30
  case "sequence-check":
29
31
  return "cli";
30
32
  default:
@@ -89,6 +91,13 @@ function validateFlags(command, commandArgs, parsed) {
89
91
  case "cluster":
90
92
  assertKnownFlags(parsed, command, ["json"]);
91
93
  return;
94
+ case "maintenance":
95
+ if (commandArgs[0] === "prune-events") {
96
+ assertKnownFlags(parsed, command, ["json", "dry-run", "archive", "discard", "retention-days", "batch-size"]);
97
+ return;
98
+ }
99
+ assertKnownFlags(parsed, command, []);
100
+ return;
92
101
  case "sequence-check":
93
102
  assertKnownFlags(parsed, command, ["json", "base"]);
94
103
  return;
@@ -185,7 +194,7 @@ export async function runCli(argv, options) {
185
194
  const json = parsed.flags.get("json") === true;
186
195
  if (command === "help") {
187
196
  const topic = commandArgs[0];
188
- if (topic === "linear" || topic === "repo" || topic === "issue" || topic === "service" || topic === "cluster") {
197
+ if (topic === "linear" || topic === "repo" || topic === "issue" || topic === "service" || topic === "cluster" || topic === "maintenance") {
189
198
  writeOutput(stdout, `${helpTextFor(topic)}\n`);
190
199
  return 0;
191
200
  }
@@ -206,7 +215,7 @@ export async function runCli(argv, options) {
206
215
  ? "linear"
207
216
  : command === "repo"
208
217
  ? "repo"
209
- : command === "issue" || command === "service" || command === "cluster"
218
+ : command === "issue" || command === "service" || command === "cluster" || command === "maintenance"
210
219
  ? command
211
220
  : "root";
212
221
  writeOutput(stdout, `${helpTextFor(helpTopic)}\n`);
@@ -354,6 +363,21 @@ export async function runCli(argv, options) {
354
363
  runCommand,
355
364
  });
356
365
  }
366
+ if (command === "maintenance") {
367
+ const issueData = await ensureIssueDataAccess(data, config);
368
+ if (!data) {
369
+ data = issueData;
370
+ ownsData = true;
371
+ }
372
+ return await handleMaintenanceCommand({
373
+ commandArgs,
374
+ parsed,
375
+ json,
376
+ stdout,
377
+ data: issueData,
378
+ config,
379
+ });
380
+ }
357
381
  if (command === "dashboard") {
358
382
  const { handleWatchCommand } = await import("./commands/watch.js");
359
383
  return await handleWatchCommand({ config, parsed });
@@ -23,6 +23,7 @@ const FOLLOWUP_INTENT_DEVELOPER_INSTRUCTIONS = [
23
23
  "Use only the text and state facts in the current prompt.",
24
24
  "Return only the requested JSON object.",
25
25
  ].join("\n");
26
+ const MAX_STDOUT_MESSAGES_PER_DRAIN = 100;
26
27
  export function resolveCodexAppServerLaunch(config) {
27
28
  if (!config.sourceBashrc) {
28
29
  return {
@@ -50,6 +51,7 @@ export class CodexAppServerClient extends EventEmitter {
50
51
  nextRequestId = 1;
51
52
  pending = new Map();
52
53
  stdoutBuffer = "";
54
+ drainScheduled = false;
53
55
  started = false;
54
56
  stopping = false;
55
57
  startPromise;
@@ -360,6 +362,7 @@ export class CodexAppServerClient extends EventEmitter {
360
362
  this.started = false;
361
363
  this.child = undefined;
362
364
  this.stdoutBuffer = "";
365
+ this.drainScheduled = false;
363
366
  const log = this.stopping ? this.logger.info.bind(this.logger) : this.logger.warn.bind(this.logger);
364
367
  log({
365
368
  code: code ?? 1,
@@ -374,11 +377,12 @@ export class CodexAppServerClient extends EventEmitter {
374
377
  if (this.stdoutBuffer.length > 50 * 1024 * 1024) {
375
378
  this.logger.error({ bufferSize: this.stdoutBuffer.length }, "Codex app-server stdout buffer exceeded 50 MB — killing process");
376
379
  this.stdoutBuffer = "";
380
+ this.drainScheduled = false;
377
381
  this.rejectAllPending(new Error("Codex app-server stdout buffer overflow"));
378
382
  this.child?.kill("SIGTERM");
379
383
  return;
380
384
  }
381
- this.drainMessages();
385
+ this.scheduleDrainMessages();
382
386
  });
383
387
  const initializeResponse = await this.sendRequest("initialize", {
384
388
  clientInfo: {
@@ -438,6 +442,7 @@ export class CodexAppServerClient extends EventEmitter {
438
442
  this.started = false;
439
443
  this.child = undefined;
440
444
  this.stdoutBuffer = "";
445
+ this.drainScheduled = false;
441
446
  this.logger.error({
442
447
  error: sanitizeDiagnosticText(error.message),
443
448
  pendingRequestCount: this.pending.size,
@@ -446,7 +451,8 @@ export class CodexAppServerClient extends EventEmitter {
446
451
  child?.kill("SIGTERM");
447
452
  }
448
453
  drainMessages() {
449
- while (true) {
454
+ let processed = 0;
455
+ while (processed < MAX_STDOUT_MESSAGES_PER_DRAIN) {
450
456
  const newlineIndex = this.stdoutBuffer.indexOf("\n");
451
457
  if (newlineIndex === -1) {
452
458
  return;
@@ -458,6 +464,7 @@ export class CodexAppServerClient extends EventEmitter {
458
464
  }
459
465
  try {
460
466
  this.handleMessage(JSON.parse(line));
467
+ processed += 1;
461
468
  }
462
469
  catch (error) {
463
470
  const err = error instanceof Error ? error : new Error(String(error));
@@ -471,6 +478,19 @@ export class CodexAppServerClient extends EventEmitter {
471
478
  return;
472
479
  }
473
480
  }
481
+ if (this.stdoutBuffer.includes("\n")) {
482
+ this.scheduleDrainMessages();
483
+ }
484
+ }
485
+ scheduleDrainMessages() {
486
+ if (this.drainScheduled) {
487
+ return;
488
+ }
489
+ this.drainScheduled = true;
490
+ setImmediate(() => {
491
+ this.drainScheduled = false;
492
+ this.drainMessages();
493
+ });
474
494
  }
475
495
  handleMessage(message) {
476
496
  if ("method" in message && "id" in message) {
package/dist/config.js CHANGED
@@ -131,6 +131,9 @@ const configSchema = z.object({
131
131
  database: z.object({
132
132
  path: z.string().min(1).default(getDefaultDatabasePath()),
133
133
  wal: z.boolean().default(true),
134
+ event_retention_days: z.number().int().positive().default(7),
135
+ archive_old_events: z.boolean().default(false),
136
+ archive_path: z.string().min(1).optional(),
134
137
  }),
135
138
  linear: z.object({
136
139
  webhook_secret_env: z.string().default("LINEAR_WEBHOOK_SECRET"),
@@ -555,6 +558,9 @@ export function loadConfig(configPath = process.env.PATCHRELAY_CONFIG ?? getDefa
555
558
  database: {
556
559
  path: ensureAbsolutePath(env.PATCHRELAY_DB_PATH ?? parsed.database.path),
557
560
  wal: parsed.database.wal,
561
+ eventRetentionDays: parsed.database.event_retention_days,
562
+ archiveOldEvents: parsed.database.archive_old_events,
563
+ archivePath: ensureAbsolutePath(parsed.database.archive_path ?? path.join(path.dirname(env.PATCHRELAY_DB_PATH ?? parsed.database.path), "archive")),
558
564
  },
559
565
  linear: {
560
566
  webhookSecret: webhookSecret ?? "",
@@ -92,6 +92,26 @@ export class IssueStore {
92
92
  .all();
93
93
  return rows.map(mapIssueRow);
94
94
  }
95
+ // Recovery net for a dangling active slot: an issue whose
96
+ // `active_run_id` still points at a run that has already reached a
97
+ // terminal status. This happens when the post-run finalize never ran
98
+ // to completion — almost always a service restart landing between
99
+ // `finishRun` (which marks the run terminal) and the issue write that
100
+ // clears `active_run_id` and arms the next wake. The Codex
101
+ // `turn/completed` notification that would finalize it never re-fires
102
+ // after restart, and every idle/recovery pass gates on
103
+ // `active_run_id IS NULL`, so the issue is invisible to all of them
104
+ // and freezes indefinitely. The orchestrator clears the slot so the
105
+ // idle reconciler can route the issue forward (review_fix, etc.).
106
+ listIssuesWithTerminalActiveRun() {
107
+ const rows = this.connection
108
+ .prepare(`SELECT i.* FROM issues i
109
+ JOIN runs r ON r.id = i.active_run_id
110
+ WHERE i.active_run_id IS NOT NULL
111
+ AND r.status IN ('completed', 'failed', 'released', 'superseded')`)
112
+ .all();
113
+ return rows.map(mapIssueRow);
114
+ }
95
115
  // Safety net for orphaned wakes: any delegated, non-terminal issue
96
116
  // with at least one unprocessed session event but no active run.
97
117
  // The orchestrator's enqueueIssue is the only path that drains these
@@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS runs (
49
49
  linear_issue_id TEXT NOT NULL,
50
50
  run_type TEXT NOT NULL DEFAULT 'implementation',
51
51
  status TEXT NOT NULL,
52
+ launch_phase TEXT,
52
53
  source_head_sha TEXT,
53
54
  prompt_text TEXT,
54
55
  thread_id TEXT,
@@ -248,6 +249,7 @@ CREATE INDEX IF NOT EXISTS idx_issue_sessions_key ON issue_sessions(issue_key);
248
249
  CREATE INDEX IF NOT EXISTS idx_issue_sessions_lease ON issue_sessions(leased_until, session_state);
249
250
  CREATE INDEX IF NOT EXISTS idx_issue_session_events_issue ON issue_session_events(project_id, linear_issue_id, id);
250
251
  CREATE INDEX IF NOT EXISTS idx_issue_session_events_pending ON issue_session_events(processed_at, project_id, linear_issue_id, id);
252
+ CREATE INDEX IF NOT EXISTS idx_webhook_events_retention ON webhook_events(processing_status, received_at, id);
251
253
  CREATE INDEX IF NOT EXISTS idx_run_thread_events_run ON run_thread_events(run_id, id);
252
254
  CREATE INDEX IF NOT EXISTS idx_operator_feed_events_issue ON operator_feed_events(issue_key, id);
253
255
  CREATE INDEX IF NOT EXISTS idx_operator_feed_events_project ON operator_feed_events(project_id, id);
@@ -295,6 +297,7 @@ export function runPatchRelayMigrations(connection) {
295
297
  WHERE display_updated_at IS NULL
296
298
  `).run();
297
299
  addColumnIfMissing(connection, "runs", "source_head_sha", "TEXT");
300
+ addColumnIfMissing(connection, "runs", "launch_phase", "TEXT");
298
301
  addColumnIfMissing(connection, "runs", "completion_check_thread_id", "TEXT");
299
302
  addColumnIfMissing(connection, "runs", "completion_check_turn_id", "TEXT");
300
303
  addColumnIfMissing(connection, "runs", "completion_check_outcome", "TEXT");
@@ -20,8 +20,8 @@ export class RunStore {
20
20
  createRun(params) {
21
21
  const now = isoNow();
22
22
  const result = this.connection.prepare(`
23
- INSERT INTO runs (issue_id, project_id, linear_issue_id, run_type, status, source_head_sha, prompt_text, started_at)
24
- VALUES (?, ?, ?, ?, 'queued', ?, ?, ?)
23
+ INSERT INTO runs (issue_id, project_id, linear_issue_id, run_type, status, launch_phase, source_head_sha, prompt_text, started_at)
24
+ VALUES (?, ?, ?, ?, 'queued', 'claimed', ?, ?, ?)
25
25
  `).run(params.issueId, params.projectId, params.linearIssueId, params.runType, params.sourceHeadSha ?? null, params.promptText ?? null, now);
26
26
  const run = this.getRunById(Number(result.lastInsertRowid));
27
27
  const issue = this.issues.getIssue(params.projectId, params.linearIssueId);
@@ -76,7 +76,8 @@ export class RunStore {
76
76
  thread_id = ?,
77
77
  parent_thread_id = COALESCE(?, parent_thread_id),
78
78
  turn_id = COALESCE(?, turn_id),
79
- status = 'running'
79
+ status = 'running',
80
+ launch_phase = 'running'
80
81
  WHERE id = ?
81
82
  AND ended_at IS NULL
82
83
  AND status IN ('queued', 'running')
@@ -100,6 +101,15 @@ export class RunStore {
100
101
  updateRunTurnId(runId, turnId) {
101
102
  this.connection.prepare("UPDATE runs SET turn_id = ? WHERE id = ?").run(turnId, runId);
102
103
  }
104
+ updateLaunchPhase(runId, launchPhase) {
105
+ this.connection.prepare(`
106
+ UPDATE runs
107
+ SET launch_phase = ?
108
+ WHERE id = ?
109
+ AND ended_at IS NULL
110
+ AND status IN ('queued', 'running')
111
+ `).run(launchPhase, runId);
112
+ }
103
113
  finishRun(runId, params) {
104
114
  const now = isoNow();
105
115
  this.connection.prepare(`
@@ -42,6 +42,46 @@ export class WebhookEventStore {
42
42
  assignWebhookProject(id, projectId) {
43
43
  this.connection.prepare("UPDATE webhook_events SET project_id = ? WHERE id = ?").run(projectId, id);
44
44
  }
45
+ listArchiveableEventsBefore(cutoffIso, limit) {
46
+ const rows = this.connection.prepare(`
47
+ SELECT id, webhook_id, received_at, project_id, payload_json, processing_status
48
+ FROM webhook_events
49
+ WHERE received_at < ?
50
+ AND processing_status != 'pending'
51
+ ORDER BY received_at ASC, id ASC
52
+ LIMIT ?
53
+ `).all(cutoffIso, limit);
54
+ return rows.map((row) => ({
55
+ id: Number(row.id),
56
+ webhookId: String(row.webhook_id),
57
+ receivedAt: String(row.received_at),
58
+ ...(row.project_id != null ? { projectId: String(row.project_id) } : {}),
59
+ ...(row.payload_json != null ? { payloadJson: String(row.payload_json) } : {}),
60
+ processingStatus: String(row.processing_status),
61
+ }));
62
+ }
63
+ countArchiveableEventsBefore(cutoffIso) {
64
+ const row = this.connection.prepare(`
65
+ SELECT COUNT(*) AS count
66
+ FROM webhook_events
67
+ WHERE received_at < ?
68
+ AND processing_status != 'pending'
69
+ `).get(cutoffIso);
70
+ return Number(row?.count ?? 0);
71
+ }
72
+ deleteWebhookEventsByIds(ids) {
73
+ if (ids.length === 0)
74
+ return 0;
75
+ return this.connection.transaction(() => {
76
+ let deleted = 0;
77
+ const statement = this.connection.prepare("DELETE FROM webhook_events WHERE id = ?");
78
+ for (const id of ids) {
79
+ const result = statement.run(id);
80
+ deleted += Number(result.changes ?? 0);
81
+ }
82
+ return deleted;
83
+ })();
84
+ }
45
85
  findLatestAgentSessionIdForIssue(linearIssueId) {
46
86
  const row = this.connection.prepare(`
47
87
  SELECT COALESCE(
package/dist/db.js CHANGED
@@ -16,6 +16,7 @@ import { TrackedIssueQuery } from "./tracked-issue-query.js";
16
16
  import { WorkflowWakeResolver } from "./workflow-wake-resolver.js";
17
17
  export class PatchRelayDatabase {
18
18
  connection;
19
+ issueSessionProjection;
19
20
  telemetry = noopTelemetry;
20
21
  telemetryProxy = {
21
22
  emit: (event) => this.telemetry.emit(event),
@@ -44,7 +45,7 @@ export class PatchRelayDatabase {
44
45
  this.operatorFeed = new OperatorFeedStore(this.connection);
45
46
  this.repositories = new RepositoryLinkStore(this.connection);
46
47
  this.webhookEvents = new WebhookEventStore(this.connection);
47
- const issueSessionProjection = new ImmediateIssueSessionProjectionInvalidator({
48
+ this.issueSessionProjection = new ImmediateIssueSessionProjectionInvalidator({
48
49
  getIssue: (projectId, linearIssueId) => this.issues.getIssue(projectId, linearIssueId),
49
50
  listDependents: (projectId, blockerLinearIssueId) => this.issues.listDependents(projectId, blockerLinearIssueId),
50
51
  countUnresolvedBlockers: (projectId, linearIssueId) => this.issues.countUnresolvedBlockers(projectId, linearIssueId),
@@ -59,9 +60,9 @@ export class PatchRelayDatabase {
59
60
  }),
60
61
  telemetry: this.telemetryProxy,
61
62
  });
62
- this.issues = new IssueStore(this.connection, issueSessionProjection);
63
- this.runs = new RunStore(this.connection, mapRunRow, this.issues, issueSessionProjection, this.telemetryProxy);
64
- this.issueSessions = new IssueSessionStore(this.connection, mapIssueSessionRow, mapIssueSessionEventRow, this.issues, this.runs, issueSessionProjection, this.telemetryProxy);
63
+ this.issues = new IssueStore(this.connection, this.issueSessionProjection);
64
+ this.runs = new RunStore(this.connection, mapRunRow, this.issues, this.issueSessionProjection, this.telemetryProxy);
65
+ this.issueSessions = new IssueSessionStore(this.connection, mapIssueSessionRow, mapIssueSessionEventRow, this.issues, this.runs, this.issueSessionProjection, this.telemetryProxy);
65
66
  this.workflowWakes = new WorkflowWakeResolver(this.issues, this.issueSessions);
66
67
  this.trackedIssues = new TrackedIssueQuery(this.issues, this.issueSessions, this.workflowWakes, this.runs);
67
68
  }
@@ -79,6 +80,12 @@ export class PatchRelayDatabase {
79
80
  transaction(fn) {
80
81
  return this.connection.transaction(fn)();
81
82
  }
83
+ batchIssueSessionProjections(fn) {
84
+ return this.issueSessionProjection.batch(fn);
85
+ }
86
+ runWalCheckpoint(mode = "PASSIVE") {
87
+ return this.connection.prepare(`PRAGMA wal_checkpoint(${mode})`).all();
88
+ }
82
89
  close() {
83
90
  this.connection.close();
84
91
  }
@@ -249,6 +256,7 @@ function mapRunRow(row) {
249
256
  linearIssueId: String(row.linear_issue_id),
250
257
  runType: String(row.run_type ?? "implementation"),
251
258
  status: String(row.status),
259
+ ...(row.launch_phase !== null && row.launch_phase !== undefined ? { launchPhase: String(row.launch_phase) } : {}),
252
260
  ...(row.source_head_sha !== null ? { sourceHeadSha: String(row.source_head_sha) } : {}),
253
261
  ...(row.prompt_text !== null ? { promptText: String(row.prompt_text) } : {}),
254
262
  ...(row.thread_id !== null ? { threadId: String(row.thread_id) } : {}),
@@ -0,0 +1,100 @@
1
+ import { createWriteStream } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { once } from "node:events";
5
+ import { createGzip } from "node:zlib";
6
+ export const DEFAULT_EVENT_RETENTION_DAYS = 7;
7
+ const DEFAULT_BATCH_SIZE = 1_000;
8
+ export async function runWebhookEventRetention(params) {
9
+ const retentionDays = params.options?.retentionDays
10
+ ?? params.config.database.eventRetentionDays
11
+ ?? DEFAULT_EVENT_RETENTION_DAYS;
12
+ const cutoffIso = computeRetentionCutoffIso(params.options?.now ?? new Date(), retentionDays);
13
+ const batchSize = Math.max(1, Math.floor(params.options?.batchSize ?? DEFAULT_BATCH_SIZE));
14
+ const archiveOldEvents = params.options?.archiveOldEvents ?? params.config.database.archiveOldEvents === true;
15
+ const archivePath = params.options?.archivePath ?? params.config.database.archivePath;
16
+ const dryRun = params.options?.dryRun === true;
17
+ let scanned = 0;
18
+ let archived = 0;
19
+ let deleted = 0;
20
+ let writer;
21
+ if (dryRun) {
22
+ const remaining = params.db.webhookEvents.countArchiveableEventsBefore(cutoffIso);
23
+ return {
24
+ cutoffIso,
25
+ scanned: remaining,
26
+ archived: 0,
27
+ deleted: 0,
28
+ remaining,
29
+ dryRun,
30
+ };
31
+ }
32
+ try {
33
+ if (archiveOldEvents) {
34
+ writer = await JsonlGzipArchiveWriter.create(resolveArchiveFilePath(archivePath, params.options?.now ?? new Date()));
35
+ }
36
+ while (true) {
37
+ const records = params.db.webhookEvents.listArchiveableEventsBefore(cutoffIso, batchSize);
38
+ if (records.length === 0)
39
+ break;
40
+ scanned += records.length;
41
+ if (writer) {
42
+ await writer.writeRecords(records);
43
+ archived += records.length;
44
+ }
45
+ deleted += params.db.webhookEvents.deleteWebhookEventsByIds(records.map((record) => record.id));
46
+ if (records.length < batchSize)
47
+ break;
48
+ }
49
+ }
50
+ finally {
51
+ await writer?.close();
52
+ }
53
+ const remaining = params.db.webhookEvents.countArchiveableEventsBefore(cutoffIso);
54
+ return {
55
+ cutoffIso,
56
+ scanned,
57
+ archived,
58
+ deleted,
59
+ remaining,
60
+ ...(writer?.filePath ? { archiveFile: writer.filePath } : {}),
61
+ dryRun,
62
+ };
63
+ }
64
+ export function computeRetentionCutoffIso(now, retentionDays) {
65
+ return new Date(now.getTime() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
66
+ }
67
+ function resolveArchiveFilePath(archivePath, now) {
68
+ const root = archivePath ?? path.join(process.cwd(), "archive");
69
+ const stamp = now.toISOString().replaceAll(":", "-").replaceAll(".", "-");
70
+ return path.join(root, "webhook-events", `${stamp}.jsonl.gz`);
71
+ }
72
+ class JsonlGzipArchiveWriter {
73
+ filePath;
74
+ gzip;
75
+ output;
76
+ constructor(filePath, gzip, output) {
77
+ this.filePath = filePath;
78
+ this.gzip = gzip;
79
+ this.output = output;
80
+ }
81
+ static async create(filePath) {
82
+ await mkdir(path.dirname(filePath), { recursive: true });
83
+ const gzip = createGzip();
84
+ const output = createWriteStream(filePath, { flags: "wx" });
85
+ gzip.pipe(output);
86
+ return new JsonlGzipArchiveWriter(filePath, gzip, output);
87
+ }
88
+ async writeRecords(records) {
89
+ for (const record of records) {
90
+ const line = `${JSON.stringify(record)}\n`;
91
+ if (!this.gzip.write(line)) {
92
+ await once(this.gzip, "drain");
93
+ }
94
+ }
95
+ }
96
+ async close() {
97
+ this.gzip.end();
98
+ await once(this.output, "close");
99
+ }
100
+ }
@@ -1,9 +1,23 @@
1
1
  import { emitTelemetry, noopTelemetry } from "./telemetry.js";
2
2
  export class ImmediateIssueSessionProjectionInvalidator {
3
3
  deps;
4
+ batchDepth = 0;
5
+ pendingProjections = new Map();
4
6
  constructor(deps) {
5
7
  this.deps = deps;
6
8
  }
9
+ batch(fn) {
10
+ this.batchDepth += 1;
11
+ try {
12
+ return fn();
13
+ }
14
+ finally {
15
+ this.batchDepth -= 1;
16
+ if (this.batchDepth === 0) {
17
+ this.flushPendingProjections();
18
+ }
19
+ }
20
+ }
7
21
  issueChanged(issue, options) {
8
22
  const dependents = this.deps.listDependents(issue.projectId, issue.linearIssueId);
9
23
  this.emitInvalidated("issue_changed", issue.projectId, issue.linearIssueId, issue.issueKey, 1 + dependents.length);
@@ -32,12 +46,26 @@ export class ImmediateIssueSessionProjectionInvalidator {
32
46
  this.projectIssueById(projectId, linearIssueId, "issue_session_events_changed");
33
47
  }
34
48
  projectIssueById(projectId, linearIssueId, reason) {
49
+ if (this.batchDepth > 0) {
50
+ this.queueProjection({ projectId, linearIssueId, reason });
51
+ return;
52
+ }
35
53
  const issue = this.deps.getIssue(projectId, linearIssueId);
36
54
  if (issue) {
37
55
  this.projectIssue(issue, reason);
38
56
  }
39
57
  }
40
58
  projectIssue(issue, reason, options) {
59
+ if (this.batchDepth > 0) {
60
+ this.queueProjection({
61
+ projectId: issue.projectId,
62
+ linearIssueId: issue.linearIssueId,
63
+ issue,
64
+ reason,
65
+ ...(options ? { options } : {}),
66
+ });
67
+ return;
68
+ }
41
69
  const beforeWaitingReason = this.deps.getIssueSessionWaitingReason?.(issue.projectId, issue.linearIssueId);
42
70
  this.deps.projectIssue(issue, options);
43
71
  this.emitReprojected(reason, issue);
@@ -87,4 +115,32 @@ export class ImmediateIssueSessionProjectionInvalidator {
87
115
  ...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
88
116
  });
89
117
  }
118
+ queueProjection(projection) {
119
+ const key = `${projection.projectId}::${projection.linearIssueId}`;
120
+ const current = this.pendingProjections.get(key);
121
+ this.pendingProjections.set(key, {
122
+ projectId: projection.projectId,
123
+ linearIssueId: projection.linearIssueId,
124
+ issue: projection.issue ?? current?.issue,
125
+ reason: projection.reason,
126
+ options: mergeProjectionOptions(current?.options, projection.options),
127
+ });
128
+ }
129
+ flushPendingProjections() {
130
+ const pending = Array.from(this.pendingProjections.values());
131
+ this.pendingProjections.clear();
132
+ for (const projection of pending) {
133
+ const issue = projection.issue ?? this.deps.getIssue(projection.projectId, projection.linearIssueId);
134
+ if (issue) {
135
+ this.projectIssue(issue, projection.reason, projection.options);
136
+ }
137
+ }
138
+ }
139
+ }
140
+ function mergeProjectionOptions(current, next) {
141
+ if (!current)
142
+ return next;
143
+ if (!next)
144
+ return current;
145
+ return { ...current, ...next };
90
146
  }
@@ -104,53 +104,55 @@ export class RunLauncher {
104
104
  }
105
105
  claimRun(params) {
106
106
  return this.db.issueSessions.withIssueSessionLease(params.item.projectId, params.item.issueId, params.leaseId, () => {
107
- const fresh = this.db.issues.getIssue(params.item.projectId, params.item.issueId);
108
- if (!fresh || fresh.activeRunId !== undefined)
109
- return undefined;
110
- const wakeIssue = params.materializeLegacyPendingWake(fresh, {
111
- projectId: params.item.projectId,
112
- linearIssueId: params.item.issueId,
113
- leaseId: params.leaseId,
114
- });
115
- const freshWake = params.resolveRunWake(wakeIssue);
116
- if (!freshWake || freshWake.runType !== params.runType)
117
- return undefined;
118
- const created = this.db.runs.createRun({
119
- issueId: fresh.id,
120
- projectId: params.item.projectId,
121
- linearIssueId: params.item.issueId,
122
- runType: params.runType,
123
- ...(params.sourceHeadSha ? { sourceHeadSha: params.sourceHeadSha } : {}),
124
- promptText: params.prompt,
125
- });
126
- const failureHeadSha = typeof params.effectiveContext?.failureHeadSha === "string"
127
- ? params.effectiveContext.failureHeadSha
128
- : typeof params.effectiveContext?.headSha === "string" ? params.effectiveContext.headSha : undefined;
129
- const failureSignature = typeof params.effectiveContext?.failureSignature === "string" ? params.effectiveContext.failureSignature : undefined;
130
- this.db.issues.upsertIssue({
131
- projectId: params.item.projectId,
132
- linearIssueId: params.item.issueId,
133
- pendingRunType: null,
134
- pendingRunContextJson: null,
135
- activeRunId: created.id,
136
- branchName: params.branchName,
137
- worktreePath: params.worktreePath,
138
- factoryState: params.runType === "implementation" ? "implementing"
139
- : params.runType === "ci_repair" ? "repairing_ci"
140
- : params.runType === "review_fix" || params.runType === "branch_upkeep" ? "changes_requested"
141
- : params.runType === "queue_repair" ? "repairing_queue"
142
- : "implementing",
143
- ...((params.runType === "ci_repair" || params.runType === "queue_repair") && failureSignature
144
- ? {
145
- lastAttemptedFailureSignature: failureSignature,
146
- lastAttemptedFailureHeadSha: failureHeadSha ?? null,
147
- lastAttemptedFailureAt: new Date().toISOString(),
148
- }
149
- : {}),
107
+ return this.db.batchIssueSessionProjections(() => {
108
+ const fresh = this.db.issues.getIssue(params.item.projectId, params.item.issueId);
109
+ if (!fresh || fresh.activeRunId !== undefined)
110
+ return undefined;
111
+ const wakeIssue = params.materializeLegacyPendingWake(fresh, {
112
+ projectId: params.item.projectId,
113
+ linearIssueId: params.item.issueId,
114
+ leaseId: params.leaseId,
115
+ });
116
+ const freshWake = params.resolveRunWake(wakeIssue);
117
+ if (!freshWake || freshWake.runType !== params.runType)
118
+ return undefined;
119
+ const created = this.db.runs.createRun({
120
+ issueId: fresh.id,
121
+ projectId: params.item.projectId,
122
+ linearIssueId: params.item.issueId,
123
+ runType: params.runType,
124
+ ...(params.sourceHeadSha ? { sourceHeadSha: params.sourceHeadSha } : {}),
125
+ promptText: params.prompt,
126
+ });
127
+ const failureHeadSha = typeof params.effectiveContext?.failureHeadSha === "string"
128
+ ? params.effectiveContext.failureHeadSha
129
+ : typeof params.effectiveContext?.headSha === "string" ? params.effectiveContext.headSha : undefined;
130
+ const failureSignature = typeof params.effectiveContext?.failureSignature === "string" ? params.effectiveContext.failureSignature : undefined;
131
+ this.db.issues.upsertIssue({
132
+ projectId: params.item.projectId,
133
+ linearIssueId: params.item.issueId,
134
+ pendingRunType: null,
135
+ pendingRunContextJson: null,
136
+ activeRunId: created.id,
137
+ branchName: params.branchName,
138
+ worktreePath: params.worktreePath,
139
+ factoryState: params.runType === "implementation" ? "implementing"
140
+ : params.runType === "ci_repair" ? "repairing_ci"
141
+ : params.runType === "review_fix" || params.runType === "branch_upkeep" ? "changes_requested"
142
+ : params.runType === "queue_repair" ? "repairing_queue"
143
+ : "implementing",
144
+ ...((params.runType === "ci_repair" || params.runType === "queue_repair") && failureSignature
145
+ ? {
146
+ lastAttemptedFailureSignature: failureSignature,
147
+ lastAttemptedFailureHeadSha: failureHeadSha ?? null,
148
+ lastAttemptedFailureAt: new Date().toISOString(),
149
+ }
150
+ : {}),
151
+ });
152
+ this.db.issueSessions.consumeIssueSessionEvents(params.item.projectId, params.item.issueId, freshWake.eventIds, created.id);
153
+ this.db.issueSessions.setIssueSessionLastWakeReason(params.item.projectId, params.item.issueId, freshWake.wakeReason ?? null);
154
+ return created;
150
155
  });
151
- this.db.issueSessions.consumeIssueSessionEvents(params.item.projectId, params.item.issueId, freshWake.eventIds, created.id);
152
- this.db.issueSessions.setIssueSessionLastWakeReason(params.item.projectId, params.item.issueId, freshWake.wakeReason ?? null);
153
- return created;
154
156
  });
155
157
  }
156
158
  async launchTurn(params) {
@@ -186,6 +188,7 @@ export class RunLauncher {
186
188
  if (prepareResult.ran && prepareResult.exitCode !== 0) {
187
189
  throw new Error(`prepare-worktree hook failed (exit ${prepareResult.exitCode}): ${prepareResult.stderr?.slice(0, 500) ?? ""}`);
188
190
  }
191
+ this.db.runs.updateLaunchPhase(params.run.id, "worktree_prepared");
189
192
  params.assertLaunchLease(params.run, "before starting the Codex turn");
190
193
  const compactThread = shouldCompactThread(params.issue, params.issueSession?.threadGeneration, params.effectiveContext);
191
194
  if (compactThread && params.issue.threadId) {
@@ -200,9 +203,11 @@ export class RunLauncher {
200
203
  createdThreadForRun = true;
201
204
  this.db.issueSessions.upsertIssueWithLease({ projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId }, { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, threadId });
202
205
  }
206
+ this.db.runs.updateLaunchPhase(params.run.id, "thread_started");
203
207
  try {
204
208
  const turn = await this.codex.startTurn({ threadId, cwd: params.worktreePath, input: params.prompt });
205
209
  turnId = turn.turnId;
210
+ this.db.runs.updateLaunchPhase(params.run.id, "turn_started");
206
211
  }
207
212
  catch (turnError) {
208
213
  const msg = turnError instanceof Error ? turnError.message : String(turnError);
@@ -214,6 +219,7 @@ export class RunLauncher {
214
219
  this.db.issueSessions.upsertIssueWithLease({ projectId: params.project.id, linearIssueId: params.issue.linearIssueId, leaseId: params.leaseId }, { projectId: params.project.id, linearIssueId: params.issue.linearIssueId, threadId });
215
220
  const turn = await this.codex.startTurn({ threadId, cwd: params.worktreePath, input: params.prompt });
216
221
  turnId = turn.turnId;
222
+ this.db.runs.updateLaunchPhase(params.run.id, "turn_started");
217
223
  }
218
224
  else {
219
225
  throw turnError;
@@ -26,6 +26,10 @@ import { CodexThreadMaterializingError, isThreadMaterializingError } from "./cod
26
26
  import { emitTelemetry, noopTelemetry } from "./telemetry.js";
27
27
  import { LinearIssueProjectionService } from "./linear-issue-projection.js";
28
28
  import { RunAdmissionController } from "./run-admission-controller.js";
29
+ // A terminal run must hold the active slot for at least this long before
30
+ // the orchestrator force-clears it, so we never race the normal
31
+ // notification-driven finalize that runs within seconds of completion.
32
+ const DANGLING_ACTIVE_RUN_MIN_AGE_MS = 2 * 60_000;
29
33
  function lowerCaseFirst(value) {
30
34
  return value ? `${value.slice(0, 1).toLowerCase()}${value.slice(1)}` : value;
31
35
  }
@@ -559,6 +563,10 @@ export class RunOrchestrator {
559
563
  for (const run of this.db.runs.listRunningRuns()) {
560
564
  await this.reconcileRun(run);
561
565
  }
566
+ // Free any issue whose active slot is pinned to an already-terminal
567
+ // run (post-run finalize interrupted by restart). Must run before the
568
+ // idle reconciler so the freed issue is routed in this same pass.
569
+ this.finalizeDanglingActiveRuns();
562
570
  // Preemptively detect stuck merge-queue PRs (conflicts visible on
563
571
  // GitHub) and dispatch queue_repair before the Steward evicts.
564
572
  await this.queueHealthMonitor.reconcile();
@@ -584,6 +592,67 @@ export class RunOrchestrator {
584
592
  isRequestedChangesRunType,
585
593
  });
586
594
  }
595
+ // Clear a dangling active slot: an issue still pointing at an
596
+ // already-terminal run via `activeRunId`. The post-run finalize was
597
+ // interrupted (almost always a restart between marking the run
598
+ // terminal and clearing the slot), so the run can never drive the
599
+ // session forward, yet every idle/recovery pass skips the issue
600
+ // because `activeRunId` is set. We re-read under the issue-session
601
+ // lease and null the slot; the idle reconciler then routes the issue
602
+ // from GitHub truth (e.g. a missed changes_requested → review_fix).
603
+ finalizeDanglingActiveRuns() {
604
+ for (const issue of this.db.issues.listIssuesWithTerminalActiveRun()) {
605
+ if (issue.activeRunId === undefined)
606
+ continue;
607
+ const run = this.db.runs.getRunById(issue.activeRunId);
608
+ // The query already filters to terminal runs; this guards against a
609
+ // race where the run advanced back to active between query and read.
610
+ if (!run || run.status === "running" || run.status === "queued")
611
+ continue;
612
+ // Hold off until the run has been terminal long enough that the
613
+ // normal notification-driven finalize has demonstrably not run —
614
+ // avoids racing a live completion that is milliseconds from clearing
615
+ // the slot itself.
616
+ const endedAtMs = run.endedAt ? Date.parse(run.endedAt) : Number.NaN;
617
+ if (Number.isFinite(endedAtMs) && Date.now() - endedAtMs < DANGLING_ACTIVE_RUN_MIN_AGE_MS)
618
+ continue;
619
+ const lease = this.claimLeaseForReconciliation(run.projectId, run.linearIssueId);
620
+ // "skip" → a live lease owns the session (a real run is in flight);
621
+ // leave it alone. "owned" → an outer local scope holds it, so we
622
+ // must not release it here.
623
+ if (lease === "skip")
624
+ continue;
625
+ try {
626
+ const cleared = this.withHeldIssueSessionLease(run.projectId, run.linearIssueId, (held) => {
627
+ const fresh = this.db.issues.getIssue(run.projectId, run.linearIssueId);
628
+ if (!fresh || fresh.activeRunId !== run.id)
629
+ return false;
630
+ this.db.issueSessions.upsertIssueWithLease(held, {
631
+ projectId: run.projectId,
632
+ linearIssueId: run.linearIssueId,
633
+ activeRunId: null,
634
+ });
635
+ return true;
636
+ });
637
+ if (cleared) {
638
+ this.logger.warn({ issueKey: issue.issueKey, runId: run.id, runType: run.runType, runStatus: run.status }, "Cleared dangling active-run slot left by a terminal run; idle reconcile will resume the issue");
639
+ this.feed?.publish({
640
+ level: "warn",
641
+ kind: "workflow",
642
+ issueKey: issue.issueKey,
643
+ projectId: run.projectId,
644
+ stage: run.runType,
645
+ status: "recovered",
646
+ summary: `Cleared stuck active slot: run #${run.id} was ${run.status} but still held the issue`,
647
+ });
648
+ }
649
+ }
650
+ finally {
651
+ if (lease !== "owned")
652
+ this.releaseIssueSessionLease(run.projectId, run.linearIssueId);
653
+ }
654
+ }
655
+ }
587
656
  async reconcileRun(run) {
588
657
  const issue = this.db.issues.getIssue(run.projectId, run.linearIssueId);
589
658
  if (!issue)
@@ -74,7 +74,7 @@ export class SerialWorkQueue {
74
74
  const nextAttempt = entry.attempt + 1;
75
75
  const retry = this.options.retryOnError?.(err, entry.item, nextAttempt);
76
76
  if (retry) {
77
- this.logger.warn({ item: entry.item, error: err.message, attempt: nextAttempt, retryDelayMs: retry.delayMs }, "Queue item processing failed; retrying");
77
+ this.logger[retry.logLevel ?? "warn"]({ item: entry.item, error: err.message, attempt: nextAttempt, retryDelayMs: retry.delayMs }, retry.message ?? "Queue item processing failed; retrying");
78
78
  this.scheduleRetry({ item: entry.item, attempt: nextAttempt }, retry.delayMs);
79
79
  continue;
80
80
  }
@@ -4,6 +4,8 @@ const ISSUE_KEY_DELIMITER = "::";
4
4
  const DEFAULT_RECONCILE_INTERVAL_MS = 5_000;
5
5
  const DEFAULT_RECONCILE_TIMEOUT_MS = 60_000;
6
6
  const DEFAULT_MAX_ACTIVE_ISSUE_RUNS = 4;
7
+ const DEFAULT_ISSUE_RUN_CAPACITY_RETRY_DELAY_MS = 5_000;
8
+ const EVENT_LOOP_MONITOR_INTERVAL_MS = 1_000;
7
9
  function makeIssueQueueKey(item) {
8
10
  return `${item.projectId}${ISSUE_KEY_DELIMITER}${item.issueId}`;
9
11
  }
@@ -21,6 +23,9 @@ export class ServiceRuntime {
21
23
  githubAppAuthError;
22
24
  startupError;
23
25
  reconcileTimer;
26
+ eventLoopMonitorTimer;
27
+ eventLoopMonitorExpectedAt = 0;
28
+ eventLoopLagMs = 0;
24
29
  reconcileInProgress = false;
25
30
  constructor(codex, logger, runReconciler, readyIssueSource, webhookProcessor, issueProcessor, options = {}) {
26
31
  this.codex = codex;
@@ -29,13 +34,23 @@ export class ServiceRuntime {
29
34
  this.readyIssueSource = readyIssueSource;
30
35
  this.options = options;
31
36
  this.webhookQueue = new SerialWorkQueue((eventId) => webhookProcessor.processWebhookEvent(eventId), logger, (eventId) => String(eventId));
32
- this.issueQueue = new SerialWorkQueue((item) => issueProcessor.processIssue(item), logger, makeIssueQueueKey, {
33
- retryOnError: (error, _item, attempt) => retrySqliteLockedQueueFailure(error, attempt),
37
+ this.issueQueue = new SerialWorkQueue((item) => this.processIssueWithCapacity(item, issueProcessor), logger, makeIssueQueueKey, {
38
+ retryOnError: (error, _item, attempt) => {
39
+ if (error instanceof IssueRunCapacityFullError) {
40
+ return {
41
+ delayMs: this.getIssueRunCapacityRetryDelayMs(),
42
+ logLevel: "debug",
43
+ message: "Issue run capacity is full; keeping item queued for retry",
44
+ };
45
+ }
46
+ return retrySqliteLockedQueueFailure(error, attempt);
47
+ },
34
48
  });
35
49
  }
36
50
  async start() {
37
51
  try {
38
52
  await this.codex.start();
53
+ this.startEventLoopMonitor();
39
54
  for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
40
55
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
41
56
  }
@@ -52,22 +67,13 @@ export class ServiceRuntime {
52
67
  async stop() {
53
68
  this.ready = false;
54
69
  this.clearBackgroundReconcile();
70
+ this.clearEventLoopMonitor();
55
71
  await this.codex.stop();
56
72
  }
57
73
  enqueueWebhookEvent(eventId, options) {
58
74
  this.webhookQueue.enqueue(eventId, options);
59
75
  }
60
76
  enqueueIssue(projectId, issueId) {
61
- if (!this.hasIssueRunCapacity()) {
62
- this.logger.warn({
63
- projectId,
64
- issueId,
65
- activeIssueRuns: this.getActiveIssueRunCount(),
66
- queuedIssueRuns: this.issueQueue.size(),
67
- maxActiveIssueRuns: this.getMaxActiveIssueRuns(),
68
- }, "Skipped issue enqueue: active run capacity is full");
69
- return;
70
- }
71
77
  this.issueQueue.enqueue({ projectId, issueId });
72
78
  }
73
79
  setLinearConnected(connected) {
@@ -83,10 +89,29 @@ export class ServiceRuntime {
83
89
  codexStarted: this.codex.isStarted(),
84
90
  linearConnected: this.linearConnected,
85
91
  githubAppAuthHealthy: this.githubAppAuthHealthy,
92
+ eventLoopLagMs: this.eventLoopLagMs,
86
93
  ...(this.githubAppAuthError ? { githubAppAuthError: this.githubAppAuthError } : {}),
87
94
  ...(this.startupError ? { startupError: this.startupError } : {}),
88
95
  };
89
96
  }
97
+ startEventLoopMonitor() {
98
+ this.clearEventLoopMonitor();
99
+ this.eventLoopMonitorExpectedAt = Date.now() + EVENT_LOOP_MONITOR_INTERVAL_MS;
100
+ const timer = setInterval(() => {
101
+ const now = Date.now();
102
+ this.eventLoopLagMs = Math.max(0, now - this.eventLoopMonitorExpectedAt);
103
+ this.eventLoopMonitorExpectedAt = now + EVENT_LOOP_MONITOR_INTERVAL_MS;
104
+ }, EVENT_LOOP_MONITOR_INTERVAL_MS);
105
+ timer.unref?.();
106
+ this.eventLoopMonitorTimer = timer;
107
+ }
108
+ clearEventLoopMonitor() {
109
+ if (this.eventLoopMonitorTimer !== undefined) {
110
+ clearInterval(this.eventLoopMonitorTimer);
111
+ this.eventLoopMonitorTimer = undefined;
112
+ }
113
+ this.eventLoopLagMs = 0;
114
+ }
90
115
  scheduleBackgroundReconcile() {
91
116
  this.clearBackgroundReconcile();
92
117
  const timer = setTimeout(() => {
@@ -114,9 +139,6 @@ export class ServiceRuntime {
114
139
  // Pick up issues that became ready outside the webhook path
115
140
  // (e.g. CLI retry, manual DB edits) without requiring a restart.
116
141
  for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
117
- if (!this.hasIssueRunCapacity()) {
118
- break;
119
- }
120
142
  this.enqueueIssue(issue.projectId, issue.linearIssueId);
121
143
  }
122
144
  }
@@ -134,11 +156,30 @@ export class ServiceRuntime {
134
156
  const configured = this.options.maxActiveIssueRuns ?? DEFAULT_MAX_ACTIVE_ISSUE_RUNS;
135
157
  return Math.max(1, Math.floor(configured));
136
158
  }
159
+ getIssueRunCapacityRetryDelayMs() {
160
+ const configured = this.options.issueRunCapacityRetryDelayMs ?? DEFAULT_ISSUE_RUN_CAPACITY_RETRY_DELAY_MS;
161
+ return Math.max(1, Math.floor(configured));
162
+ }
137
163
  getActiveIssueRunCount() {
138
164
  return Math.max(0, this.readyIssueSource.countActiveIssueRuns?.() ?? 0);
139
165
  }
140
- hasIssueRunCapacity() {
141
- return this.getActiveIssueRunCount() + this.issueQueue.size() < this.getMaxActiveIssueRuns();
166
+ async processIssueWithCapacity(item, processor) {
167
+ const activeIssueRuns = this.getActiveIssueRunCount();
168
+ const maxActiveIssueRuns = this.getMaxActiveIssueRuns();
169
+ if (activeIssueRuns >= maxActiveIssueRuns) {
170
+ throw new IssueRunCapacityFullError(activeIssueRuns, maxActiveIssueRuns);
171
+ }
172
+ await processor.processIssue(item);
173
+ }
174
+ }
175
+ class IssueRunCapacityFullError extends Error {
176
+ activeIssueRuns;
177
+ maxActiveIssueRuns;
178
+ constructor(activeIssueRuns, maxActiveIssueRuns) {
179
+ super(`active issue run capacity is full (${activeIssueRuns}/${maxActiveIssueRuns})`);
180
+ this.activeIssueRuns = activeIssueRuns;
181
+ this.maxActiveIssueRuns = maxActiveIssueRuns;
182
+ this.name = "IssueRunCapacityFullError";
142
183
  }
143
184
  }
144
185
  function promiseWithTimeout(promise, timeoutMs, label) {
package/dist/service.js CHANGED
@@ -14,6 +14,7 @@ import { ServiceStartupRecovery } from "./service-startup-recovery.js";
14
14
  import { WakeDispatcher } from "./wake-dispatcher.js";
15
15
  import { WebhookHandler } from "./webhook-handler.js";
16
16
  import { acceptIncomingWebhook } from "./service-webhooks.js";
17
+ import { runWebhookEventRetention } from "./event-retention.js";
17
18
  import { parseStringArray, TrackedIssueListQuery } from "./tracked-issue-list-query.js";
18
19
  import { AgentInputService } from "./agent-input-service.js";
19
20
  import { CodexFollowupIntentClassifier } from "./followup-intent.js";
@@ -36,6 +37,7 @@ export class PatchRelayService {
36
37
  issueActions;
37
38
  startupRecovery;
38
39
  trackedIssueListQuery;
40
+ eventRetentionTimer;
39
41
  constructor(config, db, codex, linearProvider, logger, configPath) {
40
42
  this.config = config;
41
43
  this.db = db;
@@ -181,6 +183,7 @@ export class PatchRelayService {
181
183
  });
182
184
  }
183
185
  await this.runtime.start();
186
+ this.scheduleEventRetention(60_000);
184
187
  void this.startupRecovery.recoverDelegatedIssueStateFromLinear().catch((error) => {
185
188
  const msg = error instanceof Error ? error.message : String(error);
186
189
  this.logger.warn({ error: msg }, "Background delegated issue recovery failed");
@@ -191,6 +194,10 @@ export class PatchRelayService {
191
194
  });
192
195
  }
193
196
  async stop() {
197
+ if (this.eventRetentionTimer !== undefined) {
198
+ clearTimeout(this.eventRetentionTimer);
199
+ this.eventRetentionTimer = undefined;
200
+ }
194
201
  this.githubAppTokenManager?.stop();
195
202
  await this.runtime.stop();
196
203
  }
@@ -280,6 +287,37 @@ export class PatchRelayService {
280
287
  getReadiness() {
281
288
  return this.runtime.getReadiness();
282
289
  }
290
+ scheduleEventRetention(delayMs = 24 * 60 * 60 * 1000) {
291
+ if (this.eventRetentionTimer !== undefined) {
292
+ clearTimeout(this.eventRetentionTimer);
293
+ }
294
+ const timer = setTimeout(() => {
295
+ void this.runEventRetentionMaintenance();
296
+ }, delayMs);
297
+ timer.unref?.();
298
+ this.eventRetentionTimer = timer;
299
+ }
300
+ async runEventRetentionMaintenance() {
301
+ try {
302
+ const result = await runWebhookEventRetention({
303
+ db: this.db,
304
+ config: this.config,
305
+ });
306
+ if (result.deleted > 0 || result.archived > 0 || result.remaining > 0) {
307
+ this.logger.info(result, "Webhook event retention maintenance completed");
308
+ }
309
+ if (this.config.database.wal) {
310
+ const checkpoint = this.db.runWalCheckpoint("PASSIVE");
311
+ this.logger.debug({ checkpoint }, "SQLite WAL checkpoint completed");
312
+ }
313
+ }
314
+ catch (error) {
315
+ this.logger.warn({ error: error instanceof Error ? error.message : String(error) }, "Webhook event retention maintenance failed");
316
+ }
317
+ finally {
318
+ this.scheduleEventRetention();
319
+ }
320
+ }
283
321
  listTrackedIssues() {
284
322
  return this.trackedIssueListQuery.listTrackedIssues();
285
323
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.75.1",
3
+ "version": "0.75.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {