patchrelay 0.74.5 → 0.74.7
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/commands/repo.js +2 -2
- package/dist/db/issue-session-store.js +46 -4
- package/dist/db/issue-store.js +9 -4
- package/dist/db/run-store.js +44 -8
- package/dist/db.js +44 -11
- package/dist/issue-session-lease-service.js +57 -3
- package/dist/issue-session-projection-invalidator.js +90 -0
- package/dist/run-orchestrator.js +66 -3
- package/dist/service.js +9 -3
- package/dist/telemetry.js +85 -0
- package/dist/wake-dispatcher.js +180 -6
- package/dist/webhook-handler.js +6 -3
- package/dist/webhooks/dependency-readiness-handler.js +57 -6
- package/dist/workflow-wake-resolver.js +5 -3
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -103,7 +103,7 @@ async function handleRepoLink(params) {
|
|
|
103
103
|
db.linearInstallations.setProjectInstallation(githubRepo, installation.id);
|
|
104
104
|
}
|
|
105
105
|
finally {
|
|
106
|
-
db.
|
|
106
|
+
db.close();
|
|
107
107
|
}
|
|
108
108
|
const serviceState = await tryManageService(params.runCommand, restartServiceCommands());
|
|
109
109
|
if (!serviceState.ok) {
|
|
@@ -150,7 +150,7 @@ async function handleRepoUnlink(params) {
|
|
|
150
150
|
db.linearInstallations.unlinkProjectInstallation(githubRepo);
|
|
151
151
|
}
|
|
152
152
|
finally {
|
|
153
|
-
db.
|
|
153
|
+
db.close();
|
|
154
154
|
}
|
|
155
155
|
const serviceState = await tryManageService(params.runCommand, restartServiceCommands());
|
|
156
156
|
if (!serviceState.ok) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { deriveSessionWakePlan, isActionableIssueSessionEventType } from "../issue-session-events.js";
|
|
2
2
|
import { mergeRequestedChangesEventJson, readRequestedChangesCoalesceKey } from "../reactive-wake-keys.js";
|
|
3
|
+
import { emitTelemetry, noopTelemetry } from "../telemetry.js";
|
|
3
4
|
import { isoNow } from "./shared.js";
|
|
4
5
|
export class IssueSessionStore {
|
|
5
6
|
connection;
|
|
@@ -7,12 +8,16 @@ export class IssueSessionStore {
|
|
|
7
8
|
mapIssueSessionEventRow;
|
|
8
9
|
issues;
|
|
9
10
|
runs;
|
|
10
|
-
|
|
11
|
+
issueSessionProjection;
|
|
12
|
+
telemetry;
|
|
13
|
+
constructor(connection, mapIssueSessionRow, mapIssueSessionEventRow, issues, runs, issueSessionProjection, telemetry = noopTelemetry) {
|
|
11
14
|
this.connection = connection;
|
|
12
15
|
this.mapIssueSessionRow = mapIssueSessionRow;
|
|
13
16
|
this.mapIssueSessionEventRow = mapIssueSessionEventRow;
|
|
14
17
|
this.issues = issues;
|
|
15
18
|
this.runs = runs;
|
|
19
|
+
this.issueSessionProjection = issueSessionProjection;
|
|
20
|
+
this.telemetry = telemetry;
|
|
16
21
|
}
|
|
17
22
|
getIssueSession(projectId, linearIssueId) {
|
|
18
23
|
const row = this.connection
|
|
@@ -31,19 +36,25 @@ export class IssueSessionStore {
|
|
|
31
36
|
WHERE project_id = ? AND linear_issue_id = ? AND dedupe_key = ? AND processed_at IS NULL
|
|
32
37
|
ORDER BY id DESC LIMIT 1
|
|
33
38
|
`).get(params.projectId, params.linearIssueId, params.dedupeKey);
|
|
34
|
-
if (existing)
|
|
39
|
+
if (existing) {
|
|
40
|
+
this.issueSessionProjection.issueSessionEventsChanged(params.projectId, params.linearIssueId);
|
|
35
41
|
return this.mapIssueSessionEventRow(existing);
|
|
42
|
+
}
|
|
36
43
|
}
|
|
37
44
|
const coalesced = this.coalescePendingRequestedChangesEvent(params);
|
|
38
|
-
if (coalesced)
|
|
45
|
+
if (coalesced) {
|
|
46
|
+
this.issueSessionProjection.issueSessionEventsChanged(params.projectId, params.linearIssueId);
|
|
39
47
|
return coalesced;
|
|
48
|
+
}
|
|
40
49
|
const now = isoNow();
|
|
41
50
|
const result = this.connection.prepare(`
|
|
42
51
|
INSERT INTO issue_session_events (
|
|
43
52
|
project_id, linear_issue_id, event_type, event_json, dedupe_key, created_at
|
|
44
53
|
) VALUES (?, ?, ?, ?, ?, ?)
|
|
45
54
|
`).run(params.projectId, params.linearIssueId, params.eventType, params.eventJson ?? null, params.dedupeKey ?? null, now);
|
|
46
|
-
|
|
55
|
+
const event = this.getIssueSessionEvent(Number(result.lastInsertRowid));
|
|
56
|
+
this.issueSessionProjection.issueSessionEventsChanged(params.projectId, params.linearIssueId);
|
|
57
|
+
return event;
|
|
47
58
|
}
|
|
48
59
|
coalescePendingRequestedChangesEvent(params) {
|
|
49
60
|
if (params.eventType !== "review_changes_requested")
|
|
@@ -104,6 +115,16 @@ export class IssueSessionStore {
|
|
|
104
115
|
SET processed_at = ?, consumed_by_run_id = ?
|
|
105
116
|
WHERE project_id = ? AND linear_issue_id = ? AND id IN (${placeholders}) AND processed_at IS NULL
|
|
106
117
|
`).run(now, runId, projectId, linearIssueId, ...eventIds);
|
|
118
|
+
this.issueSessionProjection.issueSessionEventsChanged(projectId, linearIssueId);
|
|
119
|
+
const issue = this.issues.getIssue(projectId, linearIssueId);
|
|
120
|
+
emitTelemetry(this.telemetry, {
|
|
121
|
+
type: "wake.consumed",
|
|
122
|
+
projectId,
|
|
123
|
+
linearIssueId,
|
|
124
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
125
|
+
eventIds,
|
|
126
|
+
runId,
|
|
127
|
+
});
|
|
107
128
|
}
|
|
108
129
|
dismissIssueSessionEvents(projectId, linearIssueId, eventIds) {
|
|
109
130
|
if (eventIds.length === 0)
|
|
@@ -114,13 +135,34 @@ export class IssueSessionStore {
|
|
|
114
135
|
SET processed_at = ?, consumed_by_run_id = NULL
|
|
115
136
|
WHERE project_id = ? AND linear_issue_id = ? AND id IN (${placeholders}) AND processed_at IS NULL
|
|
116
137
|
`).run(isoNow(), projectId, linearIssueId, ...eventIds);
|
|
138
|
+
this.issueSessionProjection.issueSessionEventsChanged(projectId, linearIssueId);
|
|
139
|
+
const issue = this.issues.getIssue(projectId, linearIssueId);
|
|
140
|
+
emitTelemetry(this.telemetry, {
|
|
141
|
+
type: "wake.dismissed",
|
|
142
|
+
projectId,
|
|
143
|
+
linearIssueId,
|
|
144
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
145
|
+
eventIds,
|
|
146
|
+
reason: "dismissed",
|
|
147
|
+
});
|
|
117
148
|
}
|
|
118
149
|
clearPendingIssueSessionEvents(projectId, linearIssueId) {
|
|
150
|
+
const eventIds = this.listIssueSessionEvents(projectId, linearIssueId, { pendingOnly: true }).map((event) => event.id);
|
|
119
151
|
this.connection.prepare(`
|
|
120
152
|
UPDATE issue_session_events
|
|
121
153
|
SET processed_at = ?, consumed_by_run_id = NULL
|
|
122
154
|
WHERE project_id = ? AND linear_issue_id = ? AND processed_at IS NULL
|
|
123
155
|
`).run(isoNow(), projectId, linearIssueId);
|
|
156
|
+
this.issueSessionProjection.issueSessionEventsChanged(projectId, linearIssueId);
|
|
157
|
+
const issue = this.issues.getIssue(projectId, linearIssueId);
|
|
158
|
+
emitTelemetry(this.telemetry, {
|
|
159
|
+
type: "wake.dismissed",
|
|
160
|
+
projectId,
|
|
161
|
+
linearIssueId,
|
|
162
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
163
|
+
eventIds,
|
|
164
|
+
reason: "cleared_pending",
|
|
165
|
+
});
|
|
124
166
|
}
|
|
125
167
|
hasPendingIssueSessionEvents(projectId, linearIssueId) {
|
|
126
168
|
return this.listIssueSessionEvents(projectId, linearIssueId, { pendingOnly: true })
|
package/dist/db/issue-store.js
CHANGED
|
@@ -10,10 +10,10 @@ const OPEN_CHILD_PREDICATE = `
|
|
|
10
10
|
`;
|
|
11
11
|
export class IssueStore {
|
|
12
12
|
connection;
|
|
13
|
-
|
|
14
|
-
constructor(connection,
|
|
13
|
+
issueSessionProjection;
|
|
14
|
+
constructor(connection, issueSessionProjection) {
|
|
15
15
|
this.connection = connection;
|
|
16
|
-
this.
|
|
16
|
+
this.issueSessionProjection = issueSessionProjection;
|
|
17
17
|
}
|
|
18
18
|
upsertIssue(params) {
|
|
19
19
|
const now = isoNow();
|
|
@@ -39,7 +39,7 @@ export class IssueStore {
|
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
const updated = this.getIssue(params.projectId, params.linearIssueId);
|
|
42
|
-
this.
|
|
42
|
+
this.issueSessionProjection.issueChanged(updated);
|
|
43
43
|
return updated;
|
|
44
44
|
}
|
|
45
45
|
getIssue(projectId, linearIssueId) {
|
|
@@ -163,6 +163,7 @@ export class IssueStore {
|
|
|
163
163
|
.prepare("DELETE FROM issue_dependencies WHERE project_id = ? AND linear_issue_id = ?")
|
|
164
164
|
.run(params.projectId, params.linearIssueId);
|
|
165
165
|
if (params.blockers.length === 0) {
|
|
166
|
+
this.issueSessionProjection.issueDependenciesChanged(params.projectId, params.linearIssueId);
|
|
166
167
|
return;
|
|
167
168
|
}
|
|
168
169
|
const insert = this.connection.prepare(`
|
|
@@ -180,6 +181,7 @@ export class IssueStore {
|
|
|
180
181
|
for (const blocker of params.blockers) {
|
|
181
182
|
insert.run(params.projectId, params.linearIssueId, blocker.blockerLinearIssueId, blocker.blockerIssueKey ?? null, blocker.blockerTitle ?? null, blocker.blockerCurrentLinearState ?? null, blocker.blockerCurrentLinearStateType ?? null, now);
|
|
182
183
|
}
|
|
184
|
+
this.issueSessionProjection.issueDependenciesChanged(params.projectId, params.linearIssueId);
|
|
183
185
|
}
|
|
184
186
|
updateDependencyBlockerSnapshot(params) {
|
|
185
187
|
const sets = ["updated_at = @now"];
|
|
@@ -210,6 +212,9 @@ export class IssueStore {
|
|
|
210
212
|
WHERE project_id = @projectId
|
|
211
213
|
AND blocker_linear_issue_id = @blockerLinearIssueId
|
|
212
214
|
`).run(values);
|
|
215
|
+
if (Number(result.changes) > 0) {
|
|
216
|
+
this.issueSessionProjection.dependencyBlockerChanged(params.projectId, params.blockerLinearIssueId);
|
|
217
|
+
}
|
|
213
218
|
return Number(result.changes);
|
|
214
219
|
}
|
|
215
220
|
listIssueDependencies(projectId, linearIssueId) {
|
package/dist/db/run-store.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { extractLatestAssistantSummary } from "../issue-session-events.js";
|
|
2
|
+
import { emitTelemetry, noopTelemetry } from "../telemetry.js";
|
|
2
3
|
import { isoNow } from "./shared.js";
|
|
3
4
|
export class RunStore {
|
|
4
5
|
connection;
|
|
5
6
|
mapRunRow;
|
|
6
7
|
issues;
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
issueSessionProjection;
|
|
9
|
+
telemetry;
|
|
10
|
+
constructor(connection, mapRunRow, issues, issueSessionProjection, telemetry = noopTelemetry) {
|
|
9
11
|
this.connection = connection;
|
|
10
12
|
this.mapRunRow = mapRunRow;
|
|
11
13
|
this.issues = issues;
|
|
12
|
-
this.
|
|
14
|
+
this.issueSessionProjection = issueSessionProjection;
|
|
15
|
+
this.telemetry = telemetry;
|
|
16
|
+
}
|
|
17
|
+
projectIssueRun(issue, options) {
|
|
18
|
+
this.issueSessionProjection.issueRunChanged(issue, options);
|
|
13
19
|
}
|
|
14
20
|
createRun(params) {
|
|
15
21
|
const now = isoNow();
|
|
@@ -20,8 +26,16 @@ export class RunStore {
|
|
|
20
26
|
const run = this.getRunById(Number(result.lastInsertRowid));
|
|
21
27
|
const issue = this.issues.getIssue(params.projectId, params.linearIssueId);
|
|
22
28
|
if (issue) {
|
|
23
|
-
this.
|
|
29
|
+
this.projectIssueRun(issue, { lastRunType: run.runType });
|
|
24
30
|
}
|
|
31
|
+
emitTelemetry(this.telemetry, {
|
|
32
|
+
type: "run.claimed",
|
|
33
|
+
projectId: params.projectId,
|
|
34
|
+
linearIssueId: params.linearIssueId,
|
|
35
|
+
runId: run.id,
|
|
36
|
+
runType: run.runType,
|
|
37
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
38
|
+
});
|
|
25
39
|
return run;
|
|
26
40
|
}
|
|
27
41
|
getRunById(id) {
|
|
@@ -72,8 +86,16 @@ export class RunStore {
|
|
|
72
86
|
return;
|
|
73
87
|
const issue = this.issues.getIssue(run.projectId, run.linearIssueId);
|
|
74
88
|
if (issue) {
|
|
75
|
-
this.
|
|
89
|
+
this.projectIssueRun(issue);
|
|
76
90
|
}
|
|
91
|
+
emitTelemetry(this.telemetry, {
|
|
92
|
+
type: "run.started",
|
|
93
|
+
projectId: run.projectId,
|
|
94
|
+
linearIssueId: run.linearIssueId,
|
|
95
|
+
runId: run.id,
|
|
96
|
+
runType: run.runType,
|
|
97
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
98
|
+
});
|
|
77
99
|
}
|
|
78
100
|
updateRunTurnId(runId, turnId) {
|
|
79
101
|
this.connection.prepare("UPDATE runs SET turn_id = ? WHERE id = ?").run(turnId, runId);
|
|
@@ -96,11 +118,25 @@ export class RunStore {
|
|
|
96
118
|
return;
|
|
97
119
|
const issue = this.issues.getIssue(run.projectId, run.linearIssueId);
|
|
98
120
|
if (issue) {
|
|
99
|
-
this.
|
|
121
|
+
this.projectIssueRun(issue, {
|
|
100
122
|
summaryText: extractLatestAssistantSummary(this.getRunById(runId) ?? run),
|
|
101
123
|
lastRunType: run.runType,
|
|
102
124
|
});
|
|
103
125
|
}
|
|
126
|
+
emitTelemetry(this.telemetry, {
|
|
127
|
+
type: params.status === "completed"
|
|
128
|
+
? "run.completed"
|
|
129
|
+
: params.status === "released"
|
|
130
|
+
? "run.released"
|
|
131
|
+
: params.status === "superseded"
|
|
132
|
+
? "run.superseded"
|
|
133
|
+
: "run.failed",
|
|
134
|
+
projectId: run.projectId,
|
|
135
|
+
linearIssueId: run.linearIssueId,
|
|
136
|
+
runId: run.id,
|
|
137
|
+
runType: run.runType,
|
|
138
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
139
|
+
});
|
|
104
140
|
}
|
|
105
141
|
// Plan §4.4: flag a still-running run as superseded. We deliberately
|
|
106
142
|
// do NOT change `status` here — the Codex turn must finish naturally
|
|
@@ -124,7 +160,7 @@ export class RunStore {
|
|
|
124
160
|
return;
|
|
125
161
|
const issue = this.issues.getIssue(run.projectId, run.linearIssueId);
|
|
126
162
|
if (issue) {
|
|
127
|
-
this.
|
|
163
|
+
this.projectIssueRun(issue, {
|
|
128
164
|
summaryText: params.reason,
|
|
129
165
|
lastRunType: run.runType,
|
|
130
166
|
});
|
|
@@ -157,7 +193,7 @@ export class RunStore {
|
|
|
157
193
|
return;
|
|
158
194
|
const issue = this.issues.getIssue(run.projectId, run.linearIssueId);
|
|
159
195
|
if (issue) {
|
|
160
|
-
this.
|
|
196
|
+
this.projectIssueRun(issue, {
|
|
161
197
|
lastRunType: run.runType,
|
|
162
198
|
});
|
|
163
199
|
}
|
package/dist/db.js
CHANGED
|
@@ -9,11 +9,17 @@ import { WebhookEventStore } from "./db/webhook-event-store.js";
|
|
|
9
9
|
import { runPatchRelayMigrations } from "./db/migrations.js";
|
|
10
10
|
import { assertPatchRelaySchemaReady } from "./db/schema-guard.js";
|
|
11
11
|
import { SqliteConnection } from "./db/shared.js";
|
|
12
|
+
import { ImmediateIssueSessionProjectionInvalidator } from "./issue-session-projection-invalidator.js";
|
|
12
13
|
import { syncIssueSessionFromIssue } from "./issue-session-projector.js";
|
|
14
|
+
import { noopTelemetry } from "./telemetry.js";
|
|
13
15
|
import { TrackedIssueQuery } from "./tracked-issue-query.js";
|
|
14
16
|
import { WorkflowWakeResolver } from "./workflow-wake-resolver.js";
|
|
15
17
|
export class PatchRelayDatabase {
|
|
16
18
|
connection;
|
|
19
|
+
telemetry = noopTelemetry;
|
|
20
|
+
telemetryProxy = {
|
|
21
|
+
emit: (event) => this.telemetry.emit(event),
|
|
22
|
+
};
|
|
17
23
|
linearInstallations;
|
|
18
24
|
operatorFeed;
|
|
19
25
|
repositories;
|
|
@@ -23,8 +29,11 @@ export class PatchRelayDatabase {
|
|
|
23
29
|
workflowWakes;
|
|
24
30
|
runs;
|
|
25
31
|
trackedIssues;
|
|
26
|
-
constructor(databasePath, wal) {
|
|
32
|
+
constructor(databasePath, wal, telemetry) {
|
|
27
33
|
this.databasePath = databasePath;
|
|
34
|
+
if (telemetry) {
|
|
35
|
+
this.telemetry = telemetry;
|
|
36
|
+
}
|
|
28
37
|
this.connection = new SqliteConnection(databasePath);
|
|
29
38
|
this.connection.pragma("foreign_keys = ON");
|
|
30
39
|
if (wal) {
|
|
@@ -34,20 +43,31 @@ export class PatchRelayDatabase {
|
|
|
34
43
|
this.operatorFeed = new OperatorFeedStore(this.connection);
|
|
35
44
|
this.repositories = new RepositoryLinkStore(this.connection);
|
|
36
45
|
this.webhookEvents = new WebhookEventStore(this.connection);
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
const issueSessionProjection = new ImmediateIssueSessionProjectionInvalidator({
|
|
47
|
+
getIssue: (projectId, linearIssueId) => this.issues.getIssue(projectId, linearIssueId),
|
|
48
|
+
listDependents: (projectId, blockerLinearIssueId) => this.issues.listDependents(projectId, blockerLinearIssueId),
|
|
49
|
+
countUnresolvedBlockers: (projectId, linearIssueId) => this.issues.countUnresolvedBlockers(projectId, linearIssueId),
|
|
50
|
+
getIssueSessionWaitingReason: (projectId, linearIssueId) => this.issueSessions.getIssueSession(projectId, linearIssueId)?.waitingReason,
|
|
51
|
+
projectIssue: (issue, options) => syncIssueSessionFromIssue({
|
|
52
|
+
connection: this.connection,
|
|
53
|
+
issues: this.issues,
|
|
54
|
+
issueSessions: this.issueSessions,
|
|
55
|
+
runs: this.runs,
|
|
56
|
+
issue,
|
|
57
|
+
...(options ? { options } : {}),
|
|
58
|
+
}),
|
|
59
|
+
telemetry: this.telemetryProxy,
|
|
60
|
+
});
|
|
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);
|
|
47
64
|
this.workflowWakes = new WorkflowWakeResolver(this.issues, this.issueSessions);
|
|
48
65
|
this.trackedIssues = new TrackedIssueQuery(this.issues, this.issueSessions, this.workflowWakes, this.runs);
|
|
49
66
|
}
|
|
50
67
|
databasePath;
|
|
68
|
+
setTelemetry(telemetry) {
|
|
69
|
+
this.telemetry = telemetry;
|
|
70
|
+
}
|
|
51
71
|
runMigrations() {
|
|
52
72
|
runPatchRelayMigrations(this.connection);
|
|
53
73
|
this.assertSchemaReady();
|
|
@@ -58,6 +78,19 @@ export class PatchRelayDatabase {
|
|
|
58
78
|
transaction(fn) {
|
|
59
79
|
return this.connection.transaction(fn)();
|
|
60
80
|
}
|
|
81
|
+
close() {
|
|
82
|
+
this.connection.close();
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Raw SQLite handle for tests ONLY. Production code must go through the
|
|
86
|
+
* stores; this exists so fixtures can backdate timestamps, force invalid
|
|
87
|
+
* edge states, and exercise the migration/schema-guard machinery — none of
|
|
88
|
+
* which the store API exposes, by design. The deliberately ugly name keeps
|
|
89
|
+
* it greppable so a production leak can't slip back in unnoticed.
|
|
90
|
+
*/
|
|
91
|
+
unsafeRawConnectionForTests() {
|
|
92
|
+
return this.connection;
|
|
93
|
+
}
|
|
61
94
|
upsertIssue(params) {
|
|
62
95
|
return this.issues.upsertIssue(params);
|
|
63
96
|
}
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { getThreadTurns } from "./codex-thread-utils.js";
|
|
3
|
+
import { emitTelemetry, noopTelemetry } from "./telemetry.js";
|
|
3
4
|
const ISSUE_SESSION_LEASE_MS = 10 * 60_000;
|
|
4
5
|
export class IssueSessionLeaseService {
|
|
5
6
|
db;
|
|
6
7
|
logger;
|
|
7
8
|
workerId;
|
|
8
9
|
readThreadWithRetry;
|
|
10
|
+
telemetry;
|
|
9
11
|
activeSessionLeases = new Map();
|
|
10
|
-
constructor(db, logger, workerId, readThreadWithRetry) {
|
|
12
|
+
constructor(db, logger, workerId, readThreadWithRetry, telemetry = noopTelemetry) {
|
|
11
13
|
this.db = db;
|
|
12
14
|
this.logger = logger;
|
|
13
15
|
this.workerId = workerId;
|
|
14
16
|
this.readThreadWithRetry = readThreadWithRetry;
|
|
17
|
+
this.telemetry = telemetry;
|
|
15
18
|
}
|
|
16
19
|
hasLocalLease(projectId, linearIssueId) {
|
|
17
20
|
return this.getValidatedLocalLeaseId(projectId, linearIssueId) !== undefined;
|
|
@@ -38,9 +41,13 @@ export class IssueSessionLeaseService {
|
|
|
38
41
|
workerId: this.workerId,
|
|
39
42
|
leasedUntil,
|
|
40
43
|
});
|
|
41
|
-
if (!acquired)
|
|
44
|
+
if (!acquired) {
|
|
45
|
+
this.emitLease("lease.acquire_failed", projectId, linearIssueId);
|
|
46
|
+
this.emitStaleLeaseInvariantIfRunnable(projectId, linearIssueId);
|
|
42
47
|
return undefined;
|
|
48
|
+
}
|
|
43
49
|
this.activeSessionLeases.set(this.issueSessionLeaseKey(projectId, linearIssueId), leaseId);
|
|
50
|
+
this.emitLease("lease.acquired", projectId, linearIssueId, leaseId);
|
|
44
51
|
return leaseId;
|
|
45
52
|
}
|
|
46
53
|
forceAcquire(projectId, linearIssueId) {
|
|
@@ -53,9 +60,12 @@ export class IssueSessionLeaseService {
|
|
|
53
60
|
workerId: this.workerId,
|
|
54
61
|
leasedUntil,
|
|
55
62
|
});
|
|
56
|
-
if (!acquired)
|
|
63
|
+
if (!acquired) {
|
|
64
|
+
this.emitLease("lease.acquire_failed", projectId, linearIssueId);
|
|
57
65
|
return undefined;
|
|
66
|
+
}
|
|
58
67
|
this.activeSessionLeases.set(this.issueSessionLeaseKey(projectId, linearIssueId), leaseId);
|
|
68
|
+
this.emitLease("lease.acquired", projectId, linearIssueId, leaseId);
|
|
59
69
|
return leaseId;
|
|
60
70
|
}
|
|
61
71
|
claimForReconciliation(projectId, linearIssueId) {
|
|
@@ -68,8 +78,12 @@ export class IssueSessionLeaseService {
|
|
|
68
78
|
return "skip";
|
|
69
79
|
const leasedUntilMs = session.leasedUntil ? Date.parse(session.leasedUntil) : undefined;
|
|
70
80
|
if (leasedUntilMs !== undefined && Number.isFinite(leasedUntilMs) && leasedUntilMs > Date.now()) {
|
|
81
|
+
this.emitStaleLeaseInvariantIfRunnable(projectId, linearIssueId);
|
|
71
82
|
return "skip";
|
|
72
83
|
}
|
|
84
|
+
if (session.leaseId) {
|
|
85
|
+
this.emitLease("lease.expired", projectId, linearIssueId, session.leaseId);
|
|
86
|
+
}
|
|
73
87
|
return this.acquire(projectId, linearIssueId) ? true : "skip";
|
|
74
88
|
}
|
|
75
89
|
async reclaimForeignRecoveryLeaseIfSafe(run, issue) {
|
|
@@ -111,6 +125,16 @@ export class IssueSessionLeaseService {
|
|
|
111
125
|
previousLeaseId: session.leaseId,
|
|
112
126
|
reclaimedLeaseId: leaseId,
|
|
113
127
|
}, "Reclaimed foreign issue-session lease for active-run recovery");
|
|
128
|
+
emitTelemetry(this.telemetry, {
|
|
129
|
+
type: "lease.reclaimed",
|
|
130
|
+
projectId: run.projectId,
|
|
131
|
+
linearIssueId: run.linearIssueId,
|
|
132
|
+
issueKey: issue.issueKey,
|
|
133
|
+
runId: run.id,
|
|
134
|
+
runType: run.runType,
|
|
135
|
+
leaseId,
|
|
136
|
+
workerId: this.workerId,
|
|
137
|
+
});
|
|
114
138
|
return true;
|
|
115
139
|
}
|
|
116
140
|
heartbeat(projectId, linearIssueId) {
|
|
@@ -136,6 +160,7 @@ export class IssueSessionLeaseService {
|
|
|
136
160
|
const leaseId = this.getValidatedLocalLeaseId(projectId, linearIssueId);
|
|
137
161
|
this.db.issueSessions.releaseIssueSessionLease(projectId, linearIssueId, leaseId);
|
|
138
162
|
this.activeSessionLeases.delete(key);
|
|
163
|
+
this.emitLease("lease.released", projectId, linearIssueId, leaseId);
|
|
139
164
|
}
|
|
140
165
|
getValidatedLocalLeaseId(projectId, linearIssueId) {
|
|
141
166
|
const key = this.issueSessionLeaseKey(projectId, linearIssueId);
|
|
@@ -151,4 +176,33 @@ export class IssueSessionLeaseService {
|
|
|
151
176
|
issueSessionLeaseKey(projectId, linearIssueId) {
|
|
152
177
|
return `${projectId}:${linearIssueId}`;
|
|
153
178
|
}
|
|
179
|
+
emitLease(type, projectId, linearIssueId, leaseId) {
|
|
180
|
+
const issue = this.db.issues.getIssue(projectId, linearIssueId);
|
|
181
|
+
emitTelemetry(this.telemetry, {
|
|
182
|
+
type,
|
|
183
|
+
projectId,
|
|
184
|
+
linearIssueId,
|
|
185
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
186
|
+
...(issue?.activeRunId ? { runId: issue.activeRunId } : {}),
|
|
187
|
+
...(leaseId ? { leaseId } : {}),
|
|
188
|
+
workerId: this.workerId,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
emitStaleLeaseInvariantIfRunnable(projectId, linearIssueId) {
|
|
192
|
+
const issue = this.db.issues.getIssue(projectId, linearIssueId);
|
|
193
|
+
const wake = this.db.workflowWakes.peekIssueWake(projectId, linearIssueId);
|
|
194
|
+
const runType = wake?.runType ?? issue?.pendingRunType;
|
|
195
|
+
if (!runType)
|
|
196
|
+
return;
|
|
197
|
+
emitTelemetry(this.telemetry, {
|
|
198
|
+
type: "health.invariant",
|
|
199
|
+
invariant: "stale_lease_blocking_runnable_work",
|
|
200
|
+
status: "observed",
|
|
201
|
+
projectId,
|
|
202
|
+
linearIssueId,
|
|
203
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
204
|
+
runType,
|
|
205
|
+
detail: "Runnable work could not acquire an issue-session lease",
|
|
206
|
+
});
|
|
207
|
+
}
|
|
154
208
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { emitTelemetry, noopTelemetry } from "./telemetry.js";
|
|
2
|
+
export class ImmediateIssueSessionProjectionInvalidator {
|
|
3
|
+
deps;
|
|
4
|
+
constructor(deps) {
|
|
5
|
+
this.deps = deps;
|
|
6
|
+
}
|
|
7
|
+
issueChanged(issue, options) {
|
|
8
|
+
const dependents = this.deps.listDependents(issue.projectId, issue.linearIssueId);
|
|
9
|
+
this.emitInvalidated("issue_changed", issue.projectId, issue.linearIssueId, issue.issueKey, 1 + dependents.length);
|
|
10
|
+
this.projectIssue(issue, "issue_changed", options);
|
|
11
|
+
for (const dependent of dependents) {
|
|
12
|
+
this.projectIssueById(dependent.projectId, dependent.linearIssueId, "issue_changed");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
issueRunChanged(issue, options) {
|
|
16
|
+
this.emitInvalidated("issue_run_changed", issue.projectId, issue.linearIssueId, issue.issueKey, 1);
|
|
17
|
+
this.projectIssue(issue, "issue_run_changed", options);
|
|
18
|
+
}
|
|
19
|
+
issueDependenciesChanged(projectId, linearIssueId) {
|
|
20
|
+
this.emitInvalidated("issue_dependencies_changed", projectId, linearIssueId, undefined, 1);
|
|
21
|
+
this.projectIssueById(projectId, linearIssueId, "issue_dependencies_changed");
|
|
22
|
+
}
|
|
23
|
+
dependencyBlockerChanged(projectId, blockerLinearIssueId) {
|
|
24
|
+
const dependents = this.deps.listDependents(projectId, blockerLinearIssueId);
|
|
25
|
+
this.emitInvalidated("dependency_blocker_changed", projectId, blockerLinearIssueId, undefined, dependents.length);
|
|
26
|
+
for (const dependent of dependents) {
|
|
27
|
+
this.projectIssueById(dependent.projectId, dependent.linearIssueId, "dependency_blocker_changed");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
issueSessionEventsChanged(projectId, linearIssueId) {
|
|
31
|
+
this.emitInvalidated("issue_session_events_changed", projectId, linearIssueId, undefined, 1);
|
|
32
|
+
this.projectIssueById(projectId, linearIssueId, "issue_session_events_changed");
|
|
33
|
+
}
|
|
34
|
+
projectIssueById(projectId, linearIssueId, reason) {
|
|
35
|
+
const issue = this.deps.getIssue(projectId, linearIssueId);
|
|
36
|
+
if (issue) {
|
|
37
|
+
this.projectIssue(issue, reason);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
projectIssue(issue, reason, options) {
|
|
41
|
+
const beforeWaitingReason = this.deps.getIssueSessionWaitingReason?.(issue.projectId, issue.linearIssueId);
|
|
42
|
+
this.deps.projectIssue(issue, options);
|
|
43
|
+
this.emitReprojected(reason, issue);
|
|
44
|
+
const unresolved = this.deps.countUnresolvedBlockers?.(issue.projectId, issue.linearIssueId);
|
|
45
|
+
const afterWaitingReason = this.deps.getIssueSessionWaitingReason?.(issue.projectId, issue.linearIssueId);
|
|
46
|
+
if (unresolved === 0
|
|
47
|
+
&& beforeWaitingReason?.startsWith("Blocked by ")
|
|
48
|
+
&& !afterWaitingReason?.startsWith("Blocked by ")) {
|
|
49
|
+
emitTelemetry(this.deps.telemetry ?? noopTelemetry, {
|
|
50
|
+
type: "health.invariant",
|
|
51
|
+
invariant: "stale_blocked_read_model",
|
|
52
|
+
status: "repaired",
|
|
53
|
+
projectId: issue.projectId,
|
|
54
|
+
linearIssueId: issue.linearIssueId,
|
|
55
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
56
|
+
detail: "Projection cleared stale blocked waiting reason after blockers resolved",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
else if (unresolved === 0 && afterWaitingReason?.startsWith("Blocked by ")) {
|
|
60
|
+
emitTelemetry(this.deps.telemetry ?? noopTelemetry, {
|
|
61
|
+
type: "health.invariant",
|
|
62
|
+
invariant: "stale_blocked_read_model",
|
|
63
|
+
status: "observed",
|
|
64
|
+
projectId: issue.projectId,
|
|
65
|
+
linearIssueId: issue.linearIssueId,
|
|
66
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
67
|
+
detail: "Issue session still reports blocked after source blockers resolved",
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
emitInvalidated(reason, projectId, linearIssueId, issueKey, affectedCount) {
|
|
72
|
+
emitTelemetry(this.deps.telemetry ?? noopTelemetry, {
|
|
73
|
+
type: "projection.invalidated",
|
|
74
|
+
reason,
|
|
75
|
+
projectId,
|
|
76
|
+
linearIssueId,
|
|
77
|
+
...(issueKey ? { issueKey } : {}),
|
|
78
|
+
affectedCount,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
emitReprojected(reason, issue) {
|
|
82
|
+
emitTelemetry(this.deps.telemetry ?? noopTelemetry, {
|
|
83
|
+
type: "projection.reprojected",
|
|
84
|
+
reason,
|
|
85
|
+
projectId: issue.projectId,
|
|
86
|
+
linearIssueId: issue.linearIssueId,
|
|
87
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|