patchrelay 0.75.0 → 0.75.2
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.
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +4 -1
- package/dist/cli/cluster-health/index.js +21 -9
- package/dist/cli/cluster-health/local-issue-health.js +15 -6
- package/dist/cli/commands/maintenance.js +55 -0
- package/dist/cli/help.js +28 -0
- package/dist/cli/index.js +26 -2
- package/dist/cli/output.js +1 -1
- package/dist/codex-app-server.js +22 -2
- package/dist/config.js +6 -0
- package/dist/db/migrations.js +3 -0
- package/dist/db/run-store.js +13 -3
- package/dist/db/webhook-event-store.js +40 -0
- package/dist/db.js +13 -4
- package/dist/event-retention.js +100 -0
- package/dist/idle-reconciliation.js +26 -1
- package/dist/issue-session-projection-invalidator.js +56 -0
- package/dist/linear-client.js +39 -0
- package/dist/linear-issue-projection.js +79 -0
- package/dist/merged-linear-completion-reconciler.js +2 -11
- package/dist/queue-failure-policy.js +11 -0
- package/dist/run-admission-controller.js +23 -0
- package/dist/run-launcher.js +52 -46
- package/dist/run-orchestrator.js +20 -5
- package/dist/service-queue.js +40 -8
- package/dist/service-runtime.js +69 -1
- package/dist/service-startup-recovery.js +94 -13
- package/dist/service.js +43 -2
- package/dist/terminal-wake-reconciler.js +28 -0
- package/dist/webhooks/issue-dependency-sync.js +2 -11
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
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
|
-
: "
|
|
124
|
+
: command === "maintenance"
|
|
125
|
+
? "maintenance"
|
|
126
|
+
: "root");
|
|
124
127
|
}
|
|
125
128
|
export function parsePositiveIntegerFlag(value, flagName) {
|
|
126
129
|
if (typeof value !== "string") {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { hasOpenPr } from "../../pr-state.js";
|
|
2
2
|
import { collectActiveOverlapFindings } from "./active-overlap.js";
|
|
3
|
-
import { evaluateLocalIssueHealth, isResolvedDependency, needsReviewAutomation, } from "./local-issue-health.js";
|
|
3
|
+
import { evaluateLocalIssueHealth, evaluateTerminalIssueHealth, isActiveWorkflowIssue, isTerminalFailureIssue, isResolvedDependency, needsReviewAutomation, } from "./local-issue-health.js";
|
|
4
4
|
import { evaluateGitHubIssueHealth } from "./github-issue-health.js";
|
|
5
5
|
import { collectReviewQuillAttemptOwners, } from "./review-quill-probe.js";
|
|
6
6
|
import { probeOptionalService, probePatchRelayService, } from "./service-probe.js";
|
|
@@ -10,7 +10,8 @@ export async function collectClusterHealth(config, db, runCommand) {
|
|
|
10
10
|
const ciEntries = [];
|
|
11
11
|
const now = Date.now();
|
|
12
12
|
const issues = db.listIssues();
|
|
13
|
-
const
|
|
13
|
+
const activeWorkflowIssues = issues.filter((issue) => isActiveWorkflowIssue(issue));
|
|
14
|
+
const historicalTerminalIssues = issues.filter((issue) => isTerminalFailureIssue(issue));
|
|
14
15
|
const trackedByKey = new Map(issues
|
|
15
16
|
.filter((issue) => issue.issueKey)
|
|
16
17
|
.map((issue) => [issue.issueKey, issue]));
|
|
@@ -21,7 +22,7 @@ export async function collectClusterHealth(config, db, runCommand) {
|
|
|
21
22
|
scope: "service:patchrelay",
|
|
22
23
|
message: patchRelayProbe.message,
|
|
23
24
|
});
|
|
24
|
-
const snapshots =
|
|
25
|
+
const snapshots = activeWorkflowIssues.map((issue) => {
|
|
25
26
|
const tracked = db.getTrackedIssue(issue.projectId, issue.linearIssueId);
|
|
26
27
|
const deps = db.issues.listIssueDependencies(issue.projectId, issue.linearIssueId);
|
|
27
28
|
const blockedBy = deps.filter((dep) => !isResolvedDependency(dep));
|
|
@@ -99,6 +100,17 @@ export async function collectClusterHealth(config, db, runCommand) {
|
|
|
99
100
|
});
|
|
100
101
|
}
|
|
101
102
|
}
|
|
103
|
+
for (const issue of historicalTerminalIssues) {
|
|
104
|
+
const finding = evaluateTerminalIssueHealth(issue);
|
|
105
|
+
if (finding) {
|
|
106
|
+
checks.push({
|
|
107
|
+
...finding,
|
|
108
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
109
|
+
projectId: issue.projectId,
|
|
110
|
+
...(issue.prNumber !== undefined ? { prNumber: issue.prNumber } : {}),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
102
114
|
checks.push(...await collectActiveOverlapFindings(snapshots, runCommand));
|
|
103
115
|
for (const snapshot of snapshots) {
|
|
104
116
|
if (!hasOpenPr(snapshot.issue.prNumber, snapshot.issue.prState)) {
|
|
@@ -118,18 +130,18 @@ export async function collectClusterHealth(config, db, runCommand) {
|
|
|
118
130
|
}
|
|
119
131
|
}
|
|
120
132
|
const workflowFailures = checks.filter((check) => check.scope.startsWith("issue:") || check.scope.startsWith("github:"));
|
|
121
|
-
if (workflowFailures.every((check) => check.status === "pass" || check.status === "warn") &&
|
|
133
|
+
if (workflowFailures.every((check) => check.status === "pass" || check.status === "warn") && activeWorkflowIssues.length > 0) {
|
|
122
134
|
checks.push({
|
|
123
135
|
status: "pass",
|
|
124
136
|
scope: "workflow",
|
|
125
|
-
message: `All ${
|
|
137
|
+
message: `All ${activeWorkflowIssues.length} active workflow issues currently have active work, a tracked blocker, or a downstream owner`,
|
|
126
138
|
});
|
|
127
139
|
}
|
|
128
|
-
if (
|
|
140
|
+
if (activeWorkflowIssues.length === 0) {
|
|
129
141
|
checks.push({
|
|
130
142
|
status: "pass",
|
|
131
143
|
scope: "workflow",
|
|
132
|
-
message: "No
|
|
144
|
+
message: "No active workflow issues are currently tracked",
|
|
133
145
|
});
|
|
134
146
|
}
|
|
135
147
|
if (ciEntries.length > 0) {
|
|
@@ -144,8 +156,8 @@ export async function collectClusterHealth(config, db, runCommand) {
|
|
|
144
156
|
}
|
|
145
157
|
const summary = {
|
|
146
158
|
trackedIssues: issues.length,
|
|
147
|
-
openIssues:
|
|
148
|
-
activeRuns:
|
|
159
|
+
openIssues: activeWorkflowIssues.length,
|
|
160
|
+
activeRuns: activeWorkflowIssues.filter((issue) => issue.activeRunId !== undefined).length,
|
|
149
161
|
blockedIssues: snapshots.filter((snapshot) => snapshot.blockedBy.length > 0).length,
|
|
150
162
|
readyIssues: snapshots.filter((snapshot) => snapshot.readyForExecution).length,
|
|
151
163
|
ciTrackedPrs: ciEntries.length,
|
|
@@ -13,21 +13,30 @@ export function isResolvedDependency(dep) {
|
|
|
13
13
|
|| state === "cancelled";
|
|
14
14
|
}
|
|
15
15
|
export function needsReviewAutomation(issue) {
|
|
16
|
-
if (issue.factoryState === "awaiting_queue" || issue
|
|
16
|
+
if (issue.factoryState === "awaiting_queue" || !isActiveWorkflowIssue(issue)) {
|
|
17
17
|
return false;
|
|
18
18
|
}
|
|
19
19
|
return hasOpenPr(issue.prNumber, issue.prState);
|
|
20
20
|
}
|
|
21
|
-
export function
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
export function isActiveWorkflowIssue(issue) {
|
|
22
|
+
return issue.factoryState !== "done" && !isTerminalFailureIssue(issue);
|
|
23
|
+
}
|
|
24
|
+
export function isTerminalFailureIssue(issue) {
|
|
25
|
+
return issue.factoryState === "failed" || issue.factoryState === "escalated";
|
|
26
|
+
}
|
|
27
|
+
export function evaluateTerminalIssueHealth(issue) {
|
|
24
28
|
if (issue.factoryState === "failed" || issue.factoryState === "escalated") {
|
|
25
29
|
return {
|
|
26
|
-
status: "
|
|
30
|
+
status: "warn",
|
|
27
31
|
scope: "issue:terminal",
|
|
28
|
-
message: `
|
|
32
|
+
message: `Historical terminal issue is in failure state ${issue.factoryState}`,
|
|
29
33
|
};
|
|
30
34
|
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
export function evaluateLocalIssueHealth(snapshot) {
|
|
38
|
+
const { issue, session, missingTrackedBlockers, blockedBy, ageMs, readyForExecution } = snapshot;
|
|
39
|
+
const pausedNoPrWork = isUndelegatedPausedNoPrWork(issue);
|
|
31
40
|
if (missingTrackedBlockers.length > 0) {
|
|
32
41
|
return {
|
|
33
42
|
status: "fail",
|
|
@@ -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 });
|
package/dist/cli/output.js
CHANGED
|
@@ -34,7 +34,7 @@ export function formatClusterHealth(report) {
|
|
|
34
34
|
lines.push(`${marker} [${detail}] ${check.message}`);
|
|
35
35
|
}
|
|
36
36
|
lines.push("");
|
|
37
|
-
lines.push(`Summary: tracked=${report.summary.trackedIssues}
|
|
37
|
+
lines.push(`Summary: tracked=${report.summary.trackedIssues} active=${report.summary.openIssues} active_runs=${report.summary.activeRuns} blocked=${report.summary.blockedIssues} ready=${report.summary.readyIssues}`);
|
|
38
38
|
if (report.summary.ciTrackedPrs > 0) {
|
|
39
39
|
lines.push(`CI summary: prs=${report.summary.ciTrackedPrs} pending=${report.summary.ciPending} success=${report.summary.ciSuccess} failure=${report.summary.ciFailure} unknown=${report.summary.ciUnknown} missing_owner=${report.summary.ciOrphaned}`);
|
|
40
40
|
for (const entry of report.ci) {
|
package/dist/codex-app-server.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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 ?? "",
|
package/dist/db/migrations.js
CHANGED
|
@@ -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");
|
package/dist/db/run-store.js
CHANGED
|
@@ -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),
|
|
@@ -38,12 +39,13 @@ export class PatchRelayDatabase {
|
|
|
38
39
|
this.connection.pragma("foreign_keys = ON");
|
|
39
40
|
if (wal) {
|
|
40
41
|
this.connection.pragma("journal_mode = WAL");
|
|
42
|
+
this.connection.pragma("synchronous = NORMAL");
|
|
41
43
|
}
|
|
42
44
|
this.linearInstallations = new LinearInstallationStore(this.connection);
|
|
43
45
|
this.operatorFeed = new OperatorFeedStore(this.connection);
|
|
44
46
|
this.repositories = new RepositoryLinkStore(this.connection);
|
|
45
47
|
this.webhookEvents = new WebhookEventStore(this.connection);
|
|
46
|
-
|
|
48
|
+
this.issueSessionProjection = new ImmediateIssueSessionProjectionInvalidator({
|
|
47
49
|
getIssue: (projectId, linearIssueId) => this.issues.getIssue(projectId, linearIssueId),
|
|
48
50
|
listDependents: (projectId, blockerLinearIssueId) => this.issues.listDependents(projectId, blockerLinearIssueId),
|
|
49
51
|
countUnresolvedBlockers: (projectId, linearIssueId) => this.issues.countUnresolvedBlockers(projectId, linearIssueId),
|
|
@@ -58,9 +60,9 @@ export class PatchRelayDatabase {
|
|
|
58
60
|
}),
|
|
59
61
|
telemetry: this.telemetryProxy,
|
|
60
62
|
});
|
|
61
|
-
this.issues = new IssueStore(this.connection, issueSessionProjection);
|
|
62
|
-
this.runs = new RunStore(this.connection, mapRunRow, this.issues, issueSessionProjection, this.telemetryProxy);
|
|
63
|
-
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);
|
|
64
66
|
this.workflowWakes = new WorkflowWakeResolver(this.issues, this.issueSessions);
|
|
65
67
|
this.trackedIssues = new TrackedIssueQuery(this.issues, this.issueSessions, this.workflowWakes, this.runs);
|
|
66
68
|
}
|
|
@@ -78,6 +80,12 @@ export class PatchRelayDatabase {
|
|
|
78
80
|
transaction(fn) {
|
|
79
81
|
return this.connection.transaction(fn)();
|
|
80
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
|
+
}
|
|
81
89
|
close() {
|
|
82
90
|
this.connection.close();
|
|
83
91
|
}
|
|
@@ -248,6 +256,7 @@ function mapRunRow(row) {
|
|
|
248
256
|
linearIssueId: String(row.linear_issue_id),
|
|
249
257
|
runType: String(row.run_type ?? "implementation"),
|
|
250
258
|
status: String(row.status),
|
|
259
|
+
...(row.launch_phase !== null && row.launch_phase !== undefined ? { launchPhase: String(row.launch_phase) } : {}),
|
|
251
260
|
...(row.source_head_sha !== null ? { sourceHeadSha: String(row.source_head_sha) } : {}),
|
|
252
261
|
...(row.prompt_text !== null ? { promptText: String(row.prompt_text) } : {}),
|
|
253
262
|
...(row.thread_id !== null ? { threadId: String(row.thread_id) } : {}),
|