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/service-queue.js
CHANGED
|
@@ -2,24 +2,33 @@ export class SerialWorkQueue {
|
|
|
2
2
|
onDequeue;
|
|
3
3
|
logger;
|
|
4
4
|
getKey;
|
|
5
|
+
options;
|
|
5
6
|
items = [];
|
|
6
7
|
queuedKeys = new Set();
|
|
7
8
|
pending = false;
|
|
8
|
-
constructor(onDequeue, logger, getKey) {
|
|
9
|
+
constructor(onDequeue, logger, getKey, options = {}) {
|
|
9
10
|
this.onDequeue = onDequeue;
|
|
10
11
|
this.logger = logger;
|
|
11
12
|
this.getKey = getKey;
|
|
13
|
+
this.options = options;
|
|
12
14
|
}
|
|
13
15
|
enqueue(item, options) {
|
|
16
|
+
this.enqueueEntry({ item, attempt: 0 }, options);
|
|
17
|
+
}
|
|
18
|
+
size() {
|
|
19
|
+
return this.items.length;
|
|
20
|
+
}
|
|
21
|
+
enqueueEntry(entry, options) {
|
|
22
|
+
const { item } = entry;
|
|
14
23
|
const key = this.getKey?.(item);
|
|
15
24
|
if (key && this.queuedKeys.has(key)) {
|
|
16
25
|
return;
|
|
17
26
|
}
|
|
18
27
|
if (options?.priority) {
|
|
19
|
-
this.items.unshift(
|
|
28
|
+
this.items.unshift(entry);
|
|
20
29
|
}
|
|
21
30
|
else {
|
|
22
|
-
this.items.push(
|
|
31
|
+
this.items.push(entry);
|
|
23
32
|
}
|
|
24
33
|
if (key) {
|
|
25
34
|
this.queuedKeys.add(key);
|
|
@@ -31,22 +40,45 @@ export class SerialWorkQueue {
|
|
|
31
40
|
});
|
|
32
41
|
}
|
|
33
42
|
}
|
|
43
|
+
scheduleRetry(entry, delayMs) {
|
|
44
|
+
const key = this.getKey?.(entry.item);
|
|
45
|
+
if (key && this.queuedKeys.has(key)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (key) {
|
|
49
|
+
this.queuedKeys.add(key);
|
|
50
|
+
}
|
|
51
|
+
const timer = setTimeout(() => {
|
|
52
|
+
if (key) {
|
|
53
|
+
this.queuedKeys.delete(key);
|
|
54
|
+
}
|
|
55
|
+
this.enqueueEntry(entry);
|
|
56
|
+
}, delayMs);
|
|
57
|
+
timer.unref?.();
|
|
58
|
+
}
|
|
34
59
|
async drain() {
|
|
35
60
|
while (this.items.length > 0) {
|
|
36
|
-
const
|
|
37
|
-
if (
|
|
61
|
+
const entry = this.items.shift();
|
|
62
|
+
if (entry === undefined) {
|
|
38
63
|
continue;
|
|
39
64
|
}
|
|
40
|
-
const key = this.getKey?.(
|
|
65
|
+
const key = this.getKey?.(entry.item);
|
|
41
66
|
if (key) {
|
|
42
67
|
this.queuedKeys.delete(key);
|
|
43
68
|
}
|
|
44
69
|
try {
|
|
45
|
-
await this.onDequeue(
|
|
70
|
+
await this.onDequeue(entry.item);
|
|
46
71
|
}
|
|
47
72
|
catch (error) {
|
|
48
73
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
49
|
-
|
|
74
|
+
const nextAttempt = entry.attempt + 1;
|
|
75
|
+
const retry = this.options.retryOnError?.(err, entry.item, nextAttempt);
|
|
76
|
+
if (retry) {
|
|
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
|
+
this.scheduleRetry({ item: entry.item, attempt: nextAttempt }, retry.delayMs);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
this.logger.error({ item: entry.item, error: err.message, stack: err.stack }, "Queue item processing failed");
|
|
50
82
|
}
|
|
51
83
|
}
|
|
52
84
|
this.pending = false;
|
package/dist/service-runtime.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { SerialWorkQueue } from "./service-queue.js";
|
|
2
|
+
import { retrySqliteLockedQueueFailure } from "./queue-failure-policy.js";
|
|
2
3
|
const ISSUE_KEY_DELIMITER = "::";
|
|
3
4
|
const DEFAULT_RECONCILE_INTERVAL_MS = 5_000;
|
|
4
5
|
const DEFAULT_RECONCILE_TIMEOUT_MS = 60_000;
|
|
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;
|
|
5
9
|
function makeIssueQueueKey(item) {
|
|
6
10
|
return `${item.projectId}${ISSUE_KEY_DELIMITER}${item.issueId}`;
|
|
7
11
|
}
|
|
@@ -19,6 +23,9 @@ export class ServiceRuntime {
|
|
|
19
23
|
githubAppAuthError;
|
|
20
24
|
startupError;
|
|
21
25
|
reconcileTimer;
|
|
26
|
+
eventLoopMonitorTimer;
|
|
27
|
+
eventLoopMonitorExpectedAt = 0;
|
|
28
|
+
eventLoopLagMs = 0;
|
|
22
29
|
reconcileInProgress = false;
|
|
23
30
|
constructor(codex, logger, runReconciler, readyIssueSource, webhookProcessor, issueProcessor, options = {}) {
|
|
24
31
|
this.codex = codex;
|
|
@@ -27,11 +34,23 @@ export class ServiceRuntime {
|
|
|
27
34
|
this.readyIssueSource = readyIssueSource;
|
|
28
35
|
this.options = options;
|
|
29
36
|
this.webhookQueue = new SerialWorkQueue((eventId) => webhookProcessor.processWebhookEvent(eventId), logger, (eventId) => String(eventId));
|
|
30
|
-
this.issueQueue = new SerialWorkQueue((item) =>
|
|
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
|
+
},
|
|
48
|
+
});
|
|
31
49
|
}
|
|
32
50
|
async start() {
|
|
33
51
|
try {
|
|
34
52
|
await this.codex.start();
|
|
53
|
+
this.startEventLoopMonitor();
|
|
35
54
|
for (const issue of this.readyIssueSource.listIssuesReadyForExecution()) {
|
|
36
55
|
this.enqueueIssue(issue.projectId, issue.linearIssueId);
|
|
37
56
|
}
|
|
@@ -48,6 +67,7 @@ export class ServiceRuntime {
|
|
|
48
67
|
async stop() {
|
|
49
68
|
this.ready = false;
|
|
50
69
|
this.clearBackgroundReconcile();
|
|
70
|
+
this.clearEventLoopMonitor();
|
|
51
71
|
await this.codex.stop();
|
|
52
72
|
}
|
|
53
73
|
enqueueWebhookEvent(eventId, options) {
|
|
@@ -69,10 +89,29 @@ export class ServiceRuntime {
|
|
|
69
89
|
codexStarted: this.codex.isStarted(),
|
|
70
90
|
linearConnected: this.linearConnected,
|
|
71
91
|
githubAppAuthHealthy: this.githubAppAuthHealthy,
|
|
92
|
+
eventLoopLagMs: this.eventLoopLagMs,
|
|
72
93
|
...(this.githubAppAuthError ? { githubAppAuthError: this.githubAppAuthError } : {}),
|
|
73
94
|
...(this.startupError ? { startupError: this.startupError } : {}),
|
|
74
95
|
};
|
|
75
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
|
+
}
|
|
76
115
|
scheduleBackgroundReconcile() {
|
|
77
116
|
this.clearBackgroundReconcile();
|
|
78
117
|
const timer = setTimeout(() => {
|
|
@@ -113,6 +152,35 @@ export class ServiceRuntime {
|
|
|
113
152
|
}
|
|
114
153
|
}
|
|
115
154
|
}
|
|
155
|
+
getMaxActiveIssueRuns() {
|
|
156
|
+
const configured = this.options.maxActiveIssueRuns ?? DEFAULT_MAX_ACTIVE_ISSUE_RUNS;
|
|
157
|
+
return Math.max(1, Math.floor(configured));
|
|
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
|
+
}
|
|
163
|
+
getActiveIssueRunCount() {
|
|
164
|
+
return Math.max(0, this.readyIssueSource.countActiveIssueRuns?.() ?? 0);
|
|
165
|
+
}
|
|
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";
|
|
183
|
+
}
|
|
116
184
|
}
|
|
117
185
|
function promiseWithTimeout(promise, timeoutMs, label) {
|
|
118
186
|
return new Promise((resolve, reject) => {
|
|
@@ -2,13 +2,16 @@ import { appendDelegationObservedEvent } from "./delegation-audit.js";
|
|
|
2
2
|
import { deriveIssueSessionReactiveIntent } from "./issue-session.js";
|
|
3
3
|
import { isResumablePausedLocalWork } from "./paused-issue-state.js";
|
|
4
4
|
import { buildRepairWakeDedupeKey, buildRequestedChangesWakeIdentity, reactiveWakeEventType } from "./reactive-wake-keys.js";
|
|
5
|
+
import { upsertLinearIssueProjection } from "./linear-issue-projection.js";
|
|
5
6
|
export class ServiceStartupRecovery {
|
|
7
|
+
config;
|
|
6
8
|
db;
|
|
7
9
|
linearProvider;
|
|
8
10
|
linearSync;
|
|
9
11
|
enqueueIssue;
|
|
10
12
|
logger;
|
|
11
|
-
constructor(db, linearProvider, linearSync, enqueueIssue, logger) {
|
|
13
|
+
constructor(config, db, linearProvider, linearSync, enqueueIssue, logger) {
|
|
14
|
+
this.config = config;
|
|
12
15
|
this.db = db;
|
|
13
16
|
this.linearProvider = linearProvider;
|
|
14
17
|
this.linearSync = linearSync;
|
|
@@ -20,6 +23,9 @@ export class ServiceStartupRecovery {
|
|
|
20
23
|
if (issue.factoryState === "done") {
|
|
21
24
|
continue;
|
|
22
25
|
}
|
|
26
|
+
if (!issue.activeRunId) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
23
29
|
const syncedIssue = issue.agentSessionId
|
|
24
30
|
? issue
|
|
25
31
|
: (() => {
|
|
@@ -35,7 +41,11 @@ export class ServiceStartupRecovery {
|
|
|
35
41
|
if (!syncedIssue.agentSessionId) {
|
|
36
42
|
continue;
|
|
37
43
|
}
|
|
38
|
-
const
|
|
44
|
+
const activeRunId = syncedIssue.activeRunId;
|
|
45
|
+
if (!activeRunId) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const activeRun = this.db.runs.getRunById(activeRunId);
|
|
39
49
|
if (!activeRun) {
|
|
40
50
|
continue;
|
|
41
51
|
}
|
|
@@ -43,6 +53,7 @@ export class ServiceStartupRecovery {
|
|
|
43
53
|
}
|
|
44
54
|
}
|
|
45
55
|
async recoverDelegatedIssueStateFromLinear() {
|
|
56
|
+
await this.discoverDelegatedIssuesFromLinear();
|
|
46
57
|
for (const issue of this.db.issues.listIssues()) {
|
|
47
58
|
if (issue.factoryState === "done" || issue.activeRunId !== undefined) {
|
|
48
59
|
continue;
|
|
@@ -59,17 +70,7 @@ export class ServiceStartupRecovery {
|
|
|
59
70
|
if (!liveIssue) {
|
|
60
71
|
continue;
|
|
61
72
|
}
|
|
62
|
-
this.db.
|
|
63
|
-
projectId: issue.projectId,
|
|
64
|
-
linearIssueId: issue.linearIssueId,
|
|
65
|
-
blockers: liveIssue.blockedBy.map((blocker) => ({
|
|
66
|
-
blockerLinearIssueId: blocker.id,
|
|
67
|
-
...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
|
|
68
|
-
...(blocker.title ? { blockerTitle: blocker.title } : {}),
|
|
69
|
-
...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
|
|
70
|
-
...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
|
|
71
|
-
})),
|
|
72
|
-
});
|
|
73
|
+
upsertLinearIssueProjection(this.db, issue.projectId, liveIssue);
|
|
73
74
|
const delegated = liveIssue.delegateId === installation.actorId;
|
|
74
75
|
if (issue.delegatedToPatchRelay !== delegated) {
|
|
75
76
|
appendDelegationObservedEvent(this.db, {
|
|
@@ -169,6 +170,86 @@ export class ServiceStartupRecovery {
|
|
|
169
170
|
}
|
|
170
171
|
}
|
|
171
172
|
}
|
|
173
|
+
async discoverDelegatedIssuesFromLinear() {
|
|
174
|
+
for (const project of this.config.projects) {
|
|
175
|
+
const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
|
|
176
|
+
if (!installation?.actorId) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const linear = await this.linearProvider.forProject(project.id).catch(() => undefined);
|
|
180
|
+
if (!linear?.listIssuesDelegatedTo) {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const liveIssues = await linear.listIssuesDelegatedTo({
|
|
184
|
+
delegateId: installation.actorId,
|
|
185
|
+
teamIds: project.linearTeamIds,
|
|
186
|
+
}).catch((error) => {
|
|
187
|
+
this.logger.warn({
|
|
188
|
+
projectId: project.id,
|
|
189
|
+
error: error instanceof Error ? error.message : String(error),
|
|
190
|
+
}, "Failed to discover delegated Linear issues during startup recovery");
|
|
191
|
+
return [];
|
|
192
|
+
});
|
|
193
|
+
for (const liveIssue of liveIssues) {
|
|
194
|
+
if (!this.shouldRecoverDiscoveredIssue(project, liveIssue, installation.actorId)) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const existing = this.db.issues.getIssue(project.id, liveIssue.id);
|
|
198
|
+
if (existing) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
this.upsertDiscoveredDelegatedIssue(project, liveIssue);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
shouldRecoverDiscoveredIssue(project, liveIssue, actorId) {
|
|
206
|
+
if (liveIssue.delegateId !== actorId)
|
|
207
|
+
return false;
|
|
208
|
+
if (liveIssue.stateType === "completed" || liveIssue.stateType === "canceled")
|
|
209
|
+
return false;
|
|
210
|
+
if (project.linearTeamIds.length > 0 && (!liveIssue.teamId || !project.linearTeamIds.includes(liveIssue.teamId))) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
upsertDiscoveredDelegatedIssue(project, liveIssue) {
|
|
216
|
+
upsertLinearIssueProjection(this.db, project.id, liveIssue);
|
|
217
|
+
const existing = this.db.issues.getIssue(project.id, liveIssue.id);
|
|
218
|
+
const updated = this.db.issues.upsertIssue({
|
|
219
|
+
projectId: project.id,
|
|
220
|
+
linearIssueId: liveIssue.id,
|
|
221
|
+
delegatedToPatchRelay: true,
|
|
222
|
+
factoryState: existing?.factoryState ?? "delegated",
|
|
223
|
+
...(liveIssue.identifier ? { issueKey: liveIssue.identifier } : {}),
|
|
224
|
+
...(liveIssue.title ? { title: liveIssue.title } : {}),
|
|
225
|
+
...(liveIssue.description ? { description: liveIssue.description } : {}),
|
|
226
|
+
...(liveIssue.url ? { url: liveIssue.url } : {}),
|
|
227
|
+
...(liveIssue.priority != null ? { priority: liveIssue.priority } : {}),
|
|
228
|
+
...(liveIssue.estimate != null ? { estimate: liveIssue.estimate } : {}),
|
|
229
|
+
...(liveIssue.stateName ? { currentLinearState: liveIssue.stateName } : {}),
|
|
230
|
+
...(liveIssue.stateType ? { currentLinearStateType: liveIssue.stateType } : {}),
|
|
231
|
+
});
|
|
232
|
+
const hasPendingWake = this.db.workflowWakes.peekIssueWake(project.id, liveIssue.id) !== undefined;
|
|
233
|
+
const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(project.id, liveIssue.id);
|
|
234
|
+
if (!hasPendingWake && unresolvedBlockers === 0) {
|
|
235
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, liveIssue.id, {
|
|
236
|
+
projectId: project.id,
|
|
237
|
+
linearIssueId: liveIssue.id,
|
|
238
|
+
eventType: "delegated",
|
|
239
|
+
dedupeKey: `delegated:${liveIssue.id}`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (this.db.workflowWakes.peekIssueWake(project.id, liveIssue.id)) {
|
|
243
|
+
this.enqueueIssue(project.id, liveIssue.id);
|
|
244
|
+
}
|
|
245
|
+
this.logger.info({
|
|
246
|
+
issueKey: updated.issueKey,
|
|
247
|
+
projectId: project.id,
|
|
248
|
+
unresolvedBlockers,
|
|
249
|
+
}, unresolvedBlockers === 0
|
|
250
|
+
? "Discovered delegated Linear issue during startup recovery and queued implementation"
|
|
251
|
+
: "Discovered delegated blocked Linear issue during startup recovery");
|
|
252
|
+
}
|
|
172
253
|
appendReactiveWakeEvent(projectId, linearIssueId, issue, runType) {
|
|
173
254
|
const eventType = reactiveWakeEventType(runType);
|
|
174
255
|
const dedupeKey = runType === "queue_repair" || runType === "ci_repair"
|
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;
|
|
@@ -67,7 +69,10 @@ export class PatchRelayService {
|
|
|
67
69
|
leaseRelease = (projectId, issueId) => this.orchestrator.leaseService.release(projectId, issueId);
|
|
68
70
|
this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, dispatcher, logger, this.feed, undefined, agentInput, telemetry);
|
|
69
71
|
this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, dispatcher, logger, codex, this.feed);
|
|
70
|
-
const runtime = new ServiceRuntime(codex, logger, this.orchestrator, {
|
|
72
|
+
const runtime = new ServiceRuntime(codex, logger, this.orchestrator, {
|
|
73
|
+
listIssuesReadyForExecution: () => db.listIssuesReadyForExecution(),
|
|
74
|
+
countActiveIssueRuns: () => db.runs.listActiveRuns().length,
|
|
75
|
+
}, this.webhookHandler, {
|
|
71
76
|
processIssue: async (item) => {
|
|
72
77
|
await this.orchestrator.run(item);
|
|
73
78
|
},
|
|
@@ -77,7 +82,7 @@ export class PatchRelayService {
|
|
|
77
82
|
this.queryService = new IssueQueryService(db, codex, this.orchestrator);
|
|
78
83
|
this.runtime = runtime;
|
|
79
84
|
this.issueActions = new ServiceIssueActions(config, db, agentInput, codex, runtime, this.feed, logger);
|
|
80
|
-
this.startupRecovery = new ServiceStartupRecovery(db, this.linearProvider, this.orchestrator.linearSync, (projectId, issueId) => runtime.enqueueIssue(projectId, issueId), logger);
|
|
85
|
+
this.startupRecovery = new ServiceStartupRecovery(config, db, this.linearProvider, this.orchestrator.linearSync, (projectId, issueId) => runtime.enqueueIssue(projectId, issueId), logger);
|
|
81
86
|
this.trackedIssueListQuery = new TrackedIssueListQuery(db);
|
|
82
87
|
// Optional GitHub App token management for bot identity
|
|
83
88
|
const ghAppCredentials = resolveGitHubAppCredentials();
|
|
@@ -178,6 +183,7 @@ export class PatchRelayService {
|
|
|
178
183
|
});
|
|
179
184
|
}
|
|
180
185
|
await this.runtime.start();
|
|
186
|
+
this.scheduleEventRetention(60_000);
|
|
181
187
|
void this.startupRecovery.recoverDelegatedIssueStateFromLinear().catch((error) => {
|
|
182
188
|
const msg = error instanceof Error ? error.message : String(error);
|
|
183
189
|
this.logger.warn({ error: msg }, "Background delegated issue recovery failed");
|
|
@@ -188,6 +194,10 @@ export class PatchRelayService {
|
|
|
188
194
|
});
|
|
189
195
|
}
|
|
190
196
|
async stop() {
|
|
197
|
+
if (this.eventRetentionTimer !== undefined) {
|
|
198
|
+
clearTimeout(this.eventRetentionTimer);
|
|
199
|
+
this.eventRetentionTimer = undefined;
|
|
200
|
+
}
|
|
191
201
|
this.githubAppTokenManager?.stop();
|
|
192
202
|
await this.runtime.stop();
|
|
193
203
|
}
|
|
@@ -277,6 +287,37 @@ export class PatchRelayService {
|
|
|
277
287
|
getReadiness() {
|
|
278
288
|
return this.runtime.getReadiness();
|
|
279
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
|
+
}
|
|
280
321
|
listTrackedIssues() {
|
|
281
322
|
return this.trackedIssueListQuery.listTrackedIssues();
|
|
282
323
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { TERMINAL_STATES } from "./factory-state.js";
|
|
2
|
+
export class TerminalWakeReconciler {
|
|
3
|
+
db;
|
|
4
|
+
logger;
|
|
5
|
+
constructor(db, logger) {
|
|
6
|
+
this.db = db;
|
|
7
|
+
this.logger = logger;
|
|
8
|
+
}
|
|
9
|
+
reconcile() {
|
|
10
|
+
for (const issue of this.db.issues.listIssues()) {
|
|
11
|
+
if (!TERMINAL_STATES.has(issue.factoryState) || issue.activeRunId !== undefined) {
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (!this.db.issueSessions.hasPendingIssueSessionEvents(issue.projectId, issue.linearIssueId)
|
|
15
|
+
&& issue.pendingRunType === undefined) {
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(issue.projectId, issue.linearIssueId);
|
|
19
|
+
this.db.issues.upsertIssue({
|
|
20
|
+
projectId: issue.projectId,
|
|
21
|
+
linearIssueId: issue.linearIssueId,
|
|
22
|
+
pendingRunType: null,
|
|
23
|
+
pendingRunContextJson: null,
|
|
24
|
+
});
|
|
25
|
+
this.logger.info({ issueKey: issue.issueKey, factoryState: issue.factoryState }, "Reconciliation: cleared stale terminal wake");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { replaceIssueDependenciesFromLinearIssue } from "../linear-issue-projection.js";
|
|
1
2
|
import { mergeIssueMetadata } from "./decision-helpers.js";
|
|
2
3
|
/**
|
|
3
4
|
* Brings the local dependency / parent-link state for `issue` up to date.
|
|
@@ -24,17 +25,7 @@ export async function syncIssueDependencies(db, linearProvider, projectId, issue
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
if (source.relationsKnown) {
|
|
27
|
-
db
|
|
28
|
-
projectId,
|
|
29
|
-
linearIssueId: source.id,
|
|
30
|
-
blockers: source.blockedBy.map((blocker) => ({
|
|
31
|
-
blockerLinearIssueId: blocker.id,
|
|
32
|
-
...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
|
|
33
|
-
...(blocker.title ? { blockerTitle: blocker.title } : {}),
|
|
34
|
-
...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
|
|
35
|
-
...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
|
|
36
|
-
})),
|
|
37
|
-
});
|
|
28
|
+
replaceIssueDependenciesFromLinearIssue(db, projectId, source);
|
|
38
29
|
}
|
|
39
30
|
db.issues.replaceIssueParentLink({
|
|
40
31
|
projectId,
|