patchrelay 0.74.6 → 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/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 +31 -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
|
@@ -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();
|
|
@@ -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
|
+
}
|
package/dist/run-orchestrator.js
CHANGED
|
@@ -23,6 +23,7 @@ import { classifyIssue } from "./issue-class.js";
|
|
|
23
23
|
import { buildIssueTriageHash, IssueTriageService } from "./issue-triage.js";
|
|
24
24
|
import { loadConfig } from "./config.js";
|
|
25
25
|
import { CodexThreadMaterializingError, isThreadMaterializingError } from "./codex-thread-errors.js";
|
|
26
|
+
import { emitTelemetry, noopTelemetry } from "./telemetry.js";
|
|
26
27
|
function lowerCaseFirst(value) {
|
|
27
28
|
return value ? `${value.slice(0, 1).toLowerCase()}${value.slice(1)}` : value;
|
|
28
29
|
}
|
|
@@ -84,7 +85,8 @@ export class RunOrchestrator {
|
|
|
84
85
|
logger;
|
|
85
86
|
feed;
|
|
86
87
|
configPath;
|
|
87
|
-
|
|
88
|
+
telemetry;
|
|
89
|
+
constructor(config, db, codex, linearProvider, enqueueIssue, wakeDispatcherOrLogger, loggerOrFeed, feedOrConfigPath, configPathOrUndefined, telemetryOrUndefined) {
|
|
88
90
|
this.config = config;
|
|
89
91
|
this.db = db;
|
|
90
92
|
this.codex = codex;
|
|
@@ -95,6 +97,7 @@ export class RunOrchestrator {
|
|
|
95
97
|
let logger;
|
|
96
98
|
let feed;
|
|
97
99
|
let configPath;
|
|
100
|
+
const telemetry = telemetryOrUndefined ?? noopTelemetry;
|
|
98
101
|
if (wakeDispatcherOrLogger instanceof WakeDispatcher) {
|
|
99
102
|
this.wakeDispatcher = wakeDispatcherOrLogger;
|
|
100
103
|
logger = loggerOrFeed;
|
|
@@ -109,15 +112,16 @@ export class RunOrchestrator {
|
|
|
109
112
|
// gets wired below once the lease service exists. The stub is
|
|
110
113
|
// never called before the wiring completes because the run()
|
|
111
114
|
// loop is the only consumer of releaseRunAndDispatch.
|
|
112
|
-
this.wakeDispatcher = new WakeDispatcher(db, enqueueIssue, (projectId, linearIssueId) => this.leaseService?.release(projectId, linearIssueId), logger, feed);
|
|
115
|
+
this.wakeDispatcher = new WakeDispatcher(db, enqueueIssue, (projectId, linearIssueId) => this.leaseService?.release(projectId, linearIssueId), logger, feed, telemetry);
|
|
113
116
|
}
|
|
114
117
|
this.logger = logger;
|
|
115
118
|
this.feed = feed;
|
|
116
119
|
this.configPath = configPath;
|
|
120
|
+
this.telemetry = telemetry;
|
|
117
121
|
this.worktreeManager = new WorktreeManager(config);
|
|
118
122
|
this.codexRuntimeConfig = config.runner.codex;
|
|
119
123
|
this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
|
|
120
|
-
this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, this.threadPorts.readThreadWithRetry);
|
|
124
|
+
this.leaseService = new IssueSessionLeaseService(db, logger, this.workerId, this.threadPorts.readThreadWithRetry, telemetry);
|
|
121
125
|
this.activeSessionLeases = this.leaseService.activeSessionLeases;
|
|
122
126
|
this.runCompletionPolicy = new RunCompletionPolicy(config, db, logger, this.leasePorts.withHeldLease);
|
|
123
127
|
this.completionCheck = new CompletionCheckService(codex, logger);
|
|
@@ -237,9 +241,20 @@ export class RunOrchestrator {
|
|
|
237
241
|
}
|
|
238
242
|
// ─── Run ────────────────────────────────────────────────────────
|
|
239
243
|
async run(item) {
|
|
244
|
+
emitTelemetry(this.telemetry, {
|
|
245
|
+
type: "queue.dequeued",
|
|
246
|
+
projectId: item.projectId,
|
|
247
|
+
linearIssueId: item.issueId,
|
|
248
|
+
});
|
|
249
|
+
emitTelemetry(this.telemetry, {
|
|
250
|
+
type: "run.dequeued",
|
|
251
|
+
projectId: item.projectId,
|
|
252
|
+
linearIssueId: item.issueId,
|
|
253
|
+
});
|
|
240
254
|
await this.refreshCodexRuntimeConfig();
|
|
241
255
|
const project = this.config.projects.find((p) => p.id === item.projectId);
|
|
242
256
|
if (!project) {
|
|
257
|
+
this.emitRunSkipped(item, "project_not_configured");
|
|
243
258
|
this.logger.info({ projectId: item.projectId, linearIssueId: item.issueId, reason: "project_not_configured" }, "Skipped issue run: project missing from config");
|
|
244
259
|
return;
|
|
245
260
|
}
|
|
@@ -248,30 +263,38 @@ export class RunOrchestrator {
|
|
|
248
263
|
// pending wake didn't actually run. The original incident
|
|
249
264
|
// (LSR-495) was undiagnosable because these guards were silent.
|
|
250
265
|
if (this.leaseService.hasLocalLease(item.projectId, item.issueId)) {
|
|
266
|
+
this.emitRunSkipped(item, "lease_held_locally");
|
|
251
267
|
this.logger.info({ projectId: item.projectId, linearIssueId: item.issueId, reason: "lease_held_locally" }, "Skipped issue run: another in-process call still holds the lease");
|
|
252
268
|
return;
|
|
253
269
|
}
|
|
254
270
|
const initialIssue = this.db.issues.getIssue(item.projectId, item.issueId);
|
|
255
271
|
if (!initialIssue) {
|
|
272
|
+
this.emitRunSkipped(item, "issue_missing");
|
|
256
273
|
this.logger.info({ projectId: item.projectId, linearIssueId: item.issueId, reason: "issue_missing" }, "Skipped issue run: issue row not found");
|
|
257
274
|
return;
|
|
258
275
|
}
|
|
259
276
|
if (initialIssue.activeRunId !== undefined) {
|
|
277
|
+
this.emitActiveRunBlockerInvariant(initialIssue);
|
|
278
|
+
this.emitRunSkipped(item, "active_run_present", initialIssue, { activeRunId: initialIssue.activeRunId });
|
|
260
279
|
this.logger.info({ issueKey: initialIssue.issueKey, projectId: item.projectId, reason: "active_run_present", activeRunId: initialIssue.activeRunId }, "Skipped issue run: an active run is already in flight");
|
|
261
280
|
return;
|
|
262
281
|
}
|
|
263
282
|
const issue = await this.classifyTrackedIssue(initialIssue);
|
|
264
283
|
if (!issue) {
|
|
284
|
+
this.emitRunSkipped(item, "classification_dropped_issue");
|
|
265
285
|
this.logger.info({ projectId: item.projectId, linearIssueId: item.issueId, reason: "classification_dropped_issue" }, "Skipped issue run: classification returned no issue");
|
|
266
286
|
return;
|
|
267
287
|
}
|
|
268
288
|
if (issue.activeRunId !== undefined) {
|
|
289
|
+
this.emitActiveRunBlockerInvariant(issue);
|
|
290
|
+
this.emitRunSkipped(item, "active_run_present_post_classify", issue, { activeRunId: issue.activeRunId });
|
|
269
291
|
this.logger.info({ issueKey: issue.issueKey, projectId: item.projectId, reason: "active_run_present_post_classify", activeRunId: issue.activeRunId }, "Skipped issue run: an active run appeared during classification");
|
|
270
292
|
return;
|
|
271
293
|
}
|
|
272
294
|
const issueSession = this.db.issueSessions.getIssueSession(item.projectId, item.issueId);
|
|
273
295
|
const leaseId = this.leaseService.acquire(item.projectId, item.issueId);
|
|
274
296
|
if (!leaseId) {
|
|
297
|
+
this.emitRunSkipped(item, "lease_acquire_failed", issue);
|
|
275
298
|
this.logger.info({ issueKey: issue.issueKey, projectId: item.projectId, reason: "lease_acquire_failed" }, "Skipped issue run: another worker holds the session lease");
|
|
276
299
|
return;
|
|
277
300
|
}
|
|
@@ -283,19 +306,23 @@ export class RunOrchestrator {
|
|
|
283
306
|
const wakeIssue = this.materializeLegacyPendingWake(issue, { projectId: item.projectId, linearIssueId: item.issueId, leaseId });
|
|
284
307
|
const wake = this.resolveRunWake(wakeIssue);
|
|
285
308
|
if (!wake) {
|
|
309
|
+
this.emitRunSkipped(item, "no_wake_derivable", issue);
|
|
286
310
|
this.logger.info({ issueKey: issue.issueKey, projectId: item.projectId, reason: "no_wake_derivable" }, "Skipped issue run: no actionable wake derivable from pending events");
|
|
287
311
|
this.leaseService.release(item.projectId, item.issueId);
|
|
288
312
|
return;
|
|
289
313
|
}
|
|
290
314
|
const { runType, context, resumeThread } = wake;
|
|
291
315
|
if (runType === "implementation" && this.db.issues.countUnresolvedBlockers(item.projectId, item.issueId) > 0) {
|
|
316
|
+
const blockerCount = this.db.issues.countUnresolvedBlockers(item.projectId, item.issueId);
|
|
292
317
|
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(item.projectId, item.issueId);
|
|
293
318
|
this.releaseIssueSessionLease(item.projectId, item.issueId);
|
|
319
|
+
this.emitRunSkipped(item, "blocked", issue, { runType, blockerCount });
|
|
294
320
|
this.logger.info({ issueKey: issue.issueKey }, "Skipped implementation launch because the issue is blocked");
|
|
295
321
|
return;
|
|
296
322
|
}
|
|
297
323
|
const remainingZombieDelayMs = shouldDelayZombieRecoveryLaunch(issue, issueSession, runType);
|
|
298
324
|
if (remainingZombieDelayMs > 0) {
|
|
325
|
+
this.emitRunSkipped(item, "zombie_backoff", issue, { runType, remainingDelayMs: remainingZombieDelayMs });
|
|
299
326
|
this.logger.debug({ issueKey: issue.issueKey, runType, remainingZombieDelayMs }, "Deferring recovered run launch until zombie backoff elapses");
|
|
300
327
|
this.releaseIssueSessionLease(item.projectId, item.issueId);
|
|
301
328
|
return;
|
|
@@ -314,6 +341,7 @@ export class RunOrchestrator {
|
|
|
314
341
|
const dismissed = this.db.issueSessions.dismissIssueSessionEventsWithLease(lease, requestedChangesEventIds);
|
|
315
342
|
if (!dismissed) {
|
|
316
343
|
this.releaseIssueSessionLease(item.projectId, item.issueId);
|
|
344
|
+
this.emitRunSkipped(item, "lease_lost_dismissing_inactive_requested_changes_wake", issue, { runType });
|
|
317
345
|
this.logger.info({ issueKey: issue.issueKey, projectId: item.projectId, reason: "lease_lost_dismissing_inactive_requested_changes_wake" }, "Skipped issue run: lost lease while dismissing inactive requested-changes wake");
|
|
318
346
|
return;
|
|
319
347
|
}
|
|
@@ -335,6 +363,7 @@ export class RunOrchestrator {
|
|
|
335
363
|
prReviewState: launchIssue.prReviewState,
|
|
336
364
|
prState: launchIssue.prState,
|
|
337
365
|
}, "Skipped issue run: requested-changes wake is no longer active");
|
|
366
|
+
this.emitRunSkipped(item, "inactive_requested_changes_wake", issue, { runType });
|
|
338
367
|
this.releaseIssueSessionLease(item.projectId, item.issueId);
|
|
339
368
|
this.wakeDispatcher.dispatchIfWakePending(item.projectId, item.issueId);
|
|
340
369
|
return;
|
|
@@ -363,10 +392,12 @@ export class RunOrchestrator {
|
|
|
363
392
|
: issue.prHeadSha;
|
|
364
393
|
const budgetExceeded = this.runWakePlanner.budgetExceeded(issue, project, runType, isRequestedChangesRunType);
|
|
365
394
|
if (budgetExceeded) {
|
|
395
|
+
this.emitRunSkipped(item, "budget_exceeded", issue, { runType });
|
|
366
396
|
this.escalate(issue, runType, budgetExceeded);
|
|
367
397
|
return;
|
|
368
398
|
}
|
|
369
399
|
if (!this.runWakePlanner.incrementAttemptCounters(issue, { projectId: issue.projectId, linearIssueId: issue.linearIssueId, leaseId }, runType, isRequestedChangesRunType)) {
|
|
400
|
+
this.emitRunSkipped(item, "lease_lost_incrementing_attempts", issue, { runType });
|
|
370
401
|
this.releaseIssueSessionLease(item.projectId, item.issueId);
|
|
371
402
|
return;
|
|
372
403
|
}
|
|
@@ -390,6 +421,7 @@ export class RunOrchestrator {
|
|
|
390
421
|
worktreePath,
|
|
391
422
|
});
|
|
392
423
|
if (!run) {
|
|
424
|
+
this.emitRunSkipped(item, "claim_failed", issue, { runType });
|
|
393
425
|
this.releaseIssueSessionLease(item.projectId, item.issueId);
|
|
394
426
|
return;
|
|
395
427
|
}
|
|
@@ -453,6 +485,37 @@ export class RunOrchestrator {
|
|
|
453
485
|
async resetWorktreeToTrackedBranch(worktreePath, branchName, issue) {
|
|
454
486
|
await this.worktreeManager.resetWorktreeToTrackedBranch(worktreePath, branchName, issue, this.logger);
|
|
455
487
|
}
|
|
488
|
+
emitRunSkipped(item, reason, issue, details) {
|
|
489
|
+
emitTelemetry(this.telemetry, {
|
|
490
|
+
type: "run.skipped",
|
|
491
|
+
projectId: item.projectId,
|
|
492
|
+
linearIssueId: item.issueId,
|
|
493
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
494
|
+
reason,
|
|
495
|
+
...(details?.runType ? { runType: details.runType } : {}),
|
|
496
|
+
...(details?.activeRunId !== undefined ? { activeRunId: details.activeRunId } : {}),
|
|
497
|
+
...(details?.blockerCount !== undefined ? { blockerCount: details.blockerCount } : {}),
|
|
498
|
+
...(details?.remainingDelayMs !== undefined ? { remainingDelayMs: details.remainingDelayMs } : {}),
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
emitActiveRunBlockerInvariant(issue) {
|
|
502
|
+
if (issue.activeRunId === undefined)
|
|
503
|
+
return;
|
|
504
|
+
const blockerCount = this.db.issues.countUnresolvedBlockers(issue.projectId, issue.linearIssueId);
|
|
505
|
+
if (blockerCount === 0)
|
|
506
|
+
return;
|
|
507
|
+
emitTelemetry(this.telemetry, {
|
|
508
|
+
type: "health.invariant",
|
|
509
|
+
invariant: "active_run_with_unresolved_blocker",
|
|
510
|
+
status: "observed",
|
|
511
|
+
projectId: issue.projectId,
|
|
512
|
+
linearIssueId: issue.linearIssueId,
|
|
513
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
514
|
+
runId: issue.activeRunId,
|
|
515
|
+
blockerCount,
|
|
516
|
+
detail: "Run dequeue found an active run while blockers are unresolved",
|
|
517
|
+
});
|
|
518
|
+
}
|
|
456
519
|
async restoreIdleWorktree(issue) {
|
|
457
520
|
await this.worktreeManager.restoreIdleWorktree(issue, this.logger);
|
|
458
521
|
}
|
package/dist/service.js
CHANGED
|
@@ -17,6 +17,7 @@ import { acceptIncomingWebhook } from "./service-webhooks.js";
|
|
|
17
17
|
import { parseStringArray, TrackedIssueListQuery } from "./tracked-issue-list-query.js";
|
|
18
18
|
import { AgentInputService } from "./agent-input-service.js";
|
|
19
19
|
import { CodexFollowupIntentClassifier } from "./followup-intent.js";
|
|
20
|
+
import { FanoutPatchRelayTelemetry, LoggerTelemetrySink, OperatorFeedTelemetrySink } from "./telemetry.js";
|
|
20
21
|
export class PatchRelayService {
|
|
21
22
|
config;
|
|
22
23
|
db;
|
|
@@ -43,6 +44,11 @@ export class PatchRelayService {
|
|
|
43
44
|
this.configPath = configPath;
|
|
44
45
|
this.linearProvider = toLinearClientProvider(linearProvider);
|
|
45
46
|
this.feed = new OperatorEventFeed(db.operatorFeed);
|
|
47
|
+
const telemetry = new FanoutPatchRelayTelemetry([
|
|
48
|
+
new LoggerTelemetrySink(logger),
|
|
49
|
+
new OperatorFeedTelemetrySink(this.feed),
|
|
50
|
+
]);
|
|
51
|
+
db.setTelemetry(telemetry);
|
|
46
52
|
let enqueueIssue = () => {
|
|
47
53
|
throw new Error("Service runtime enqueueIssue is not initialized");
|
|
48
54
|
};
|
|
@@ -55,11 +61,11 @@ export class PatchRelayService {
|
|
|
55
61
|
// runtime owns the queue, and the lease service lives inside the
|
|
56
62
|
// orchestrator (its construction depends on the Codex client). All
|
|
57
63
|
// downstream consumers receive this single dispatcher instance.
|
|
58
|
-
const dispatcher = new WakeDispatcher(db, (projectId, issueId) => enqueueIssue(projectId, issueId), (projectId, issueId) => leaseRelease(projectId, issueId), logger, this.feed);
|
|
64
|
+
const dispatcher = new WakeDispatcher(db, (projectId, issueId) => enqueueIssue(projectId, issueId), (projectId, issueId) => leaseRelease(projectId, issueId), logger, this.feed, telemetry);
|
|
59
65
|
const agentInput = new AgentInputService(db, codex, dispatcher, logger, this.feed, new CodexFollowupIntentClassifier(codex, logger));
|
|
60
|
-
this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), dispatcher, logger, this.feed, this.configPath);
|
|
66
|
+
this.orchestrator = new RunOrchestrator(config, db, codex, this.linearProvider, (projectId, issueId) => enqueueIssue(projectId, issueId), dispatcher, logger, this.feed, this.configPath, telemetry);
|
|
61
67
|
leaseRelease = (projectId, issueId) => this.orchestrator.leaseService.release(projectId, issueId);
|
|
62
|
-
this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, dispatcher, logger, this.feed, undefined, agentInput);
|
|
68
|
+
this.webhookHandler = new WebhookHandler(config, db, this.linearProvider, codex, dispatcher, logger, this.feed, undefined, agentInput, telemetry);
|
|
63
69
|
this.githubWebhookHandler = new GitHubWebhookHandler(config, db, this.linearProvider, dispatcher, logger, codex, this.feed);
|
|
64
70
|
const runtime = new ServiceRuntime(codex, logger, this.orchestrator, { listIssuesReadyForExecution: () => db.listIssuesReadyForExecution() }, this.webhookHandler, {
|
|
65
71
|
processIssue: async (item) => {
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export const noopTelemetry = {
|
|
2
|
+
emit: () => undefined,
|
|
3
|
+
};
|
|
4
|
+
export function emitTelemetry(telemetry, event) {
|
|
5
|
+
try {
|
|
6
|
+
telemetry.emit(event);
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
// Telemetry must never affect workflow execution.
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class FanoutPatchRelayTelemetry {
|
|
13
|
+
sinks;
|
|
14
|
+
constructor(sinks) {
|
|
15
|
+
this.sinks = sinks;
|
|
16
|
+
}
|
|
17
|
+
emit(event) {
|
|
18
|
+
for (const sink of this.sinks) {
|
|
19
|
+
emitTelemetry(sink, event);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class MemoryPatchRelayTelemetry {
|
|
24
|
+
events = [];
|
|
25
|
+
emit(event) {
|
|
26
|
+
this.events.push(event);
|
|
27
|
+
}
|
|
28
|
+
list(type) {
|
|
29
|
+
if (!type)
|
|
30
|
+
return this.events;
|
|
31
|
+
return this.events.filter((event) => event.type === type);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export class LoggerTelemetrySink {
|
|
35
|
+
logger;
|
|
36
|
+
constructor(logger) {
|
|
37
|
+
this.logger = logger;
|
|
38
|
+
}
|
|
39
|
+
emit(event) {
|
|
40
|
+
this.logger.info({ telemetryEvent: event.type, ...event }, "PatchRelay telemetry event");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export class OperatorFeedTelemetrySink {
|
|
44
|
+
feed;
|
|
45
|
+
constructor(feed) {
|
|
46
|
+
this.feed = feed;
|
|
47
|
+
}
|
|
48
|
+
emit(event) {
|
|
49
|
+
const feedEvent = this.toFeedEvent(event);
|
|
50
|
+
if (feedEvent) {
|
|
51
|
+
this.feed.publish(feedEvent);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
toFeedEvent(event) {
|
|
55
|
+
switch (event.type) {
|
|
56
|
+
case "dependency.dependent_unblocked":
|
|
57
|
+
return {
|
|
58
|
+
level: "info",
|
|
59
|
+
kind: "workflow",
|
|
60
|
+
...(event.issueKey ? { issueKey: event.issueKey } : {}),
|
|
61
|
+
...(event.projectId ? { projectId: event.projectId } : {}),
|
|
62
|
+
...(event.dispatchedRunType ? { stage: event.dispatchedRunType } : {}),
|
|
63
|
+
status: "dependency_unblocked",
|
|
64
|
+
summary: event.dispatchedRunType
|
|
65
|
+
? `Dependency unblocked; ${event.dispatchedRunType} queued`
|
|
66
|
+
: "Dependency unblocked",
|
|
67
|
+
};
|
|
68
|
+
case "run.skipped":
|
|
69
|
+
if (event.reason === "blocked" || event.reason === "lease_acquire_failed" || event.reason === "no_wake_derivable") {
|
|
70
|
+
return {
|
|
71
|
+
level: event.reason === "blocked" ? "info" : "warn",
|
|
72
|
+
kind: "stage",
|
|
73
|
+
...(event.issueKey ? { issueKey: event.issueKey } : {}),
|
|
74
|
+
...(event.projectId ? { projectId: event.projectId } : {}),
|
|
75
|
+
...(event.runType ? { stage: event.runType } : {}),
|
|
76
|
+
status: "skipped",
|
|
77
|
+
summary: `Run skipped: ${event.reason}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return undefined;
|
|
81
|
+
default:
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/wake-dispatcher.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { emitTelemetry, noopTelemetry } from "./telemetry.js";
|
|
1
2
|
// Single owner of "append a session event and tell the orchestrator
|
|
2
3
|
// something might be runnable", and of "release a finished run so the
|
|
3
4
|
// next wake fires." Until this existed, 8+ call sites each made their
|
|
@@ -21,13 +22,15 @@ export class WakeDispatcher {
|
|
|
21
22
|
releaseLease;
|
|
22
23
|
logger;
|
|
23
24
|
feed;
|
|
25
|
+
telemetry;
|
|
24
26
|
currentTick;
|
|
25
|
-
constructor(db, enqueueIssue, releaseLease, logger, feed) {
|
|
27
|
+
constructor(db, enqueueIssue, releaseLease, logger, feed, telemetry = noopTelemetry) {
|
|
26
28
|
this.db = db;
|
|
27
29
|
this.enqueueIssue = enqueueIssue;
|
|
28
30
|
this.releaseLease = releaseLease;
|
|
29
31
|
this.logger = logger;
|
|
30
32
|
this.feed = feed;
|
|
33
|
+
this.telemetry = telemetry;
|
|
31
34
|
}
|
|
32
35
|
// Scope the next enqueue calls inside `fn` to a single dedupe Set.
|
|
33
36
|
// Nested ticks reuse the outermost Set so deeply nested helpers do
|
|
@@ -48,11 +51,23 @@ export class WakeDispatcher {
|
|
|
48
51
|
// would have, or undefined if the event is non-actionable / no wake
|
|
49
52
|
// exists / a run is already running (the finalizer will drain it).
|
|
50
53
|
recordEventAndDispatch(projectId, linearIssueId, event, options) {
|
|
51
|
-
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, linearIssueId, {
|
|
54
|
+
const appended = this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(projectId, linearIssueId, {
|
|
52
55
|
projectId,
|
|
53
56
|
linearIssueId,
|
|
54
57
|
...event,
|
|
55
58
|
});
|
|
59
|
+
const issue = this.db.issues.getIssue(projectId, linearIssueId);
|
|
60
|
+
if (appended) {
|
|
61
|
+
emitTelemetry(this.telemetry, {
|
|
62
|
+
type: "wake.created",
|
|
63
|
+
projectId,
|
|
64
|
+
linearIssueId,
|
|
65
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
66
|
+
eventIds: [appended.id],
|
|
67
|
+
sessionEventType: event.eventType,
|
|
68
|
+
...(event.dedupeKey ? { dedupeKey: event.dedupeKey } : {}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
56
71
|
// Honour the active tick scope (set via withTick) so callers nested
|
|
57
72
|
// inside a reconcile pass automatically dedupe without threading
|
|
58
73
|
// the Set through every helper signature.
|
|
@@ -67,22 +82,143 @@ export class WakeDispatcher {
|
|
|
67
82
|
// post-run drain via releaseRunAndDispatch.
|
|
68
83
|
dispatchIfWakePending(projectId, linearIssueId, options) {
|
|
69
84
|
const issue = this.db.issues.getIssue(projectId, linearIssueId);
|
|
70
|
-
if (issue
|
|
85
|
+
if (!issue) {
|
|
86
|
+
emitTelemetry(this.telemetry, {
|
|
87
|
+
type: "wake.suppressed",
|
|
88
|
+
projectId,
|
|
89
|
+
linearIssueId,
|
|
90
|
+
reason: "issue_missing",
|
|
91
|
+
});
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
if (issue.activeRunId !== undefined) {
|
|
95
|
+
const blockerCount = this.db.issues.countUnresolvedBlockers(projectId, linearIssueId);
|
|
96
|
+
emitTelemetry(this.telemetry, {
|
|
97
|
+
type: "wake.suppressed",
|
|
98
|
+
projectId,
|
|
99
|
+
linearIssueId,
|
|
100
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
101
|
+
reason: "active_run_present",
|
|
102
|
+
activeRunId: issue.activeRunId,
|
|
103
|
+
...(blockerCount > 0 ? { blockerCount } : {}),
|
|
104
|
+
});
|
|
105
|
+
if (blockerCount > 0) {
|
|
106
|
+
emitTelemetry(this.telemetry, {
|
|
107
|
+
type: "health.invariant",
|
|
108
|
+
invariant: "active_run_with_unresolved_blocker",
|
|
109
|
+
status: "observed",
|
|
110
|
+
projectId,
|
|
111
|
+
linearIssueId,
|
|
112
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
113
|
+
runId: issue.activeRunId,
|
|
114
|
+
blockerCount,
|
|
115
|
+
detail: "Wake suppressed because an active run exists while blockers are unresolved",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
const unresolvedBlockers = this.db.issues.countUnresolvedBlockers(projectId, linearIssueId);
|
|
121
|
+
if (unresolvedBlockers > 0) {
|
|
122
|
+
const blockerKeys = this.unresolvedBlockerKeys(projectId, linearIssueId);
|
|
123
|
+
emitTelemetry(this.telemetry, {
|
|
124
|
+
type: "wake.suppressed",
|
|
125
|
+
projectId,
|
|
126
|
+
linearIssueId,
|
|
127
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
128
|
+
reason: "blocked",
|
|
129
|
+
blockerCount: unresolvedBlockers,
|
|
130
|
+
blockerKeys,
|
|
131
|
+
});
|
|
132
|
+
const pendingBlockedWake = this.db.issueSessions.peekIssueSessionWake(projectId, linearIssueId) ?? issue.pendingRunType;
|
|
133
|
+
if (pendingBlockedWake) {
|
|
134
|
+
emitTelemetry(this.telemetry, {
|
|
135
|
+
type: "health.invariant",
|
|
136
|
+
invariant: "blocked_issue_with_pending_wake",
|
|
137
|
+
status: "observed",
|
|
138
|
+
projectId,
|
|
139
|
+
linearIssueId,
|
|
140
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
141
|
+
blockerCount: unresolvedBlockers,
|
|
142
|
+
detail: "Wake remains pending while blockers are unresolved",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
71
145
|
return undefined;
|
|
146
|
+
}
|
|
72
147
|
const wake = this.db.workflowWakes.peekIssueWake(projectId, linearIssueId);
|
|
73
148
|
// Fall back to the legacy pending_run_type column. The orchestrator
|
|
74
149
|
// materializes it into a real event at run time, but the poke still
|
|
75
150
|
// needs to happen now so the orchestrator gets called at all.
|
|
76
|
-
const runType = wake?.runType ?? issue
|
|
77
|
-
if (!runType)
|
|
151
|
+
const runType = wake?.runType ?? issue.pendingRunType;
|
|
152
|
+
if (!runType) {
|
|
153
|
+
emitTelemetry(this.telemetry, {
|
|
154
|
+
type: "wake.suppressed",
|
|
155
|
+
projectId,
|
|
156
|
+
linearIssueId,
|
|
157
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
158
|
+
reason: "no_wake_derivable",
|
|
159
|
+
});
|
|
160
|
+
if (this.db.listIssuesReadyForExecution().some((entry) => entry.projectId === projectId && entry.linearIssueId === linearIssueId)) {
|
|
161
|
+
emitTelemetry(this.telemetry, {
|
|
162
|
+
type: "health.invariant",
|
|
163
|
+
invariant: "ready_issue_not_enqueued",
|
|
164
|
+
status: "observed",
|
|
165
|
+
projectId,
|
|
166
|
+
linearIssueId,
|
|
167
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
168
|
+
detail: "Issue appears ready for execution but no wake was derivable for enqueue",
|
|
169
|
+
});
|
|
170
|
+
}
|
|
78
171
|
return undefined;
|
|
172
|
+
}
|
|
173
|
+
emitTelemetry(this.telemetry, {
|
|
174
|
+
type: "wake.derived",
|
|
175
|
+
projectId,
|
|
176
|
+
linearIssueId,
|
|
177
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
178
|
+
runType,
|
|
179
|
+
...(wake?.wakeReason ? { wakeReason: wake.wakeReason } : {}),
|
|
180
|
+
...(wake?.eventIds ? { eventIds: wake.eventIds } : {}),
|
|
181
|
+
source: wake ? (wake.eventIds.length > 0 ? "session_event" : "implicit") : "legacy_pending_run_type",
|
|
182
|
+
});
|
|
79
183
|
const tick = options?.enqueuedThisTick ?? this.currentTick;
|
|
80
184
|
const key = `${projectId}:${linearIssueId}`;
|
|
81
185
|
if (tick?.has(key)) {
|
|
186
|
+
emitTelemetry(this.telemetry, {
|
|
187
|
+
type: "wake.deduped",
|
|
188
|
+
projectId,
|
|
189
|
+
linearIssueId,
|
|
190
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
191
|
+
runType,
|
|
192
|
+
...(wake?.wakeReason ? { wakeReason: wake.wakeReason } : {}),
|
|
193
|
+
...(wake?.eventIds ? { eventIds: wake.eventIds } : {}),
|
|
194
|
+
});
|
|
195
|
+
emitTelemetry(this.telemetry, {
|
|
196
|
+
type: "queue.deduped",
|
|
197
|
+
projectId,
|
|
198
|
+
linearIssueId,
|
|
199
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
200
|
+
runType,
|
|
201
|
+
});
|
|
82
202
|
return runType;
|
|
83
203
|
}
|
|
84
204
|
tick?.add(key);
|
|
85
205
|
this.enqueueIssue(projectId, linearIssueId);
|
|
206
|
+
emitTelemetry(this.telemetry, {
|
|
207
|
+
type: "wake.dispatched",
|
|
208
|
+
projectId,
|
|
209
|
+
linearIssueId,
|
|
210
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
211
|
+
runType,
|
|
212
|
+
...(wake?.wakeReason ? { wakeReason: wake.wakeReason } : {}),
|
|
213
|
+
...(wake?.eventIds ? { eventIds: wake.eventIds } : {}),
|
|
214
|
+
});
|
|
215
|
+
emitTelemetry(this.telemetry, {
|
|
216
|
+
type: "queue.enqueued",
|
|
217
|
+
projectId,
|
|
218
|
+
linearIssueId,
|
|
219
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
220
|
+
runType,
|
|
221
|
+
});
|
|
86
222
|
return runType;
|
|
87
223
|
}
|
|
88
224
|
// Release the lease for a finished run, then drain any wake that
|
|
@@ -97,10 +233,42 @@ export class WakeDispatcher {
|
|
|
97
233
|
// check paths publish their own more-specific event and pass false.
|
|
98
234
|
releaseRunAndDispatch(params) {
|
|
99
235
|
this.releaseLease(params.run.projectId, params.run.linearIssueId);
|
|
236
|
+
emitTelemetry(this.telemetry, {
|
|
237
|
+
type: "run.released",
|
|
238
|
+
projectId: params.run.projectId,
|
|
239
|
+
linearIssueId: params.run.linearIssueId,
|
|
240
|
+
...(params.issueKey ? { issueKey: params.issueKey } : {}),
|
|
241
|
+
runId: params.run.id,
|
|
242
|
+
runType: params.run.runType,
|
|
243
|
+
});
|
|
100
244
|
const wake = this.db.workflowWakes.peekIssueWake(params.run.projectId, params.run.linearIssueId);
|
|
101
|
-
if (!wake)
|
|
245
|
+
if (!wake) {
|
|
246
|
+
emitTelemetry(this.telemetry, {
|
|
247
|
+
type: "wake.suppressed",
|
|
248
|
+
projectId: params.run.projectId,
|
|
249
|
+
linearIssueId: params.run.linearIssueId,
|
|
250
|
+
...(params.issueKey ? { issueKey: params.issueKey } : {}),
|
|
251
|
+
reason: "no_wake_derivable",
|
|
252
|
+
});
|
|
102
253
|
return undefined;
|
|
254
|
+
}
|
|
103
255
|
this.enqueueIssue(params.run.projectId, params.run.linearIssueId);
|
|
256
|
+
emitTelemetry(this.telemetry, {
|
|
257
|
+
type: "wake.dispatched",
|
|
258
|
+
projectId: params.run.projectId,
|
|
259
|
+
linearIssueId: params.run.linearIssueId,
|
|
260
|
+
...(params.issueKey ? { issueKey: params.issueKey } : {}),
|
|
261
|
+
runType: wake.runType,
|
|
262
|
+
...(wake.wakeReason ? { wakeReason: wake.wakeReason } : {}),
|
|
263
|
+
eventIds: wake.eventIds,
|
|
264
|
+
});
|
|
265
|
+
emitTelemetry(this.telemetry, {
|
|
266
|
+
type: "queue.enqueued",
|
|
267
|
+
projectId: params.run.projectId,
|
|
268
|
+
linearIssueId: params.run.linearIssueId,
|
|
269
|
+
...(params.issueKey ? { issueKey: params.issueKey } : {}),
|
|
270
|
+
runType: wake.runType,
|
|
271
|
+
});
|
|
104
272
|
if (params.publishDeferredFollowUp) {
|
|
105
273
|
this.feed?.publish({
|
|
106
274
|
level: "info",
|
|
@@ -118,4 +286,10 @@ export class WakeDispatcher {
|
|
|
118
286
|
...(wake.wakeReason ? { wakeReason: wake.wakeReason } : {}),
|
|
119
287
|
};
|
|
120
288
|
}
|
|
289
|
+
unresolvedBlockerKeys(projectId, linearIssueId) {
|
|
290
|
+
return this.db.issues.listIssueDependencies(projectId, linearIssueId)
|
|
291
|
+
.filter((entry) => entry.blockerCurrentLinearStateType !== "completed"
|
|
292
|
+
&& entry.blockerCurrentLinearState?.trim().toLowerCase() !== "done")
|
|
293
|
+
.map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId);
|
|
294
|
+
}
|
|
121
295
|
}
|
package/dist/webhook-handler.js
CHANGED
|
@@ -14,6 +14,7 @@ import { extractLatestAssistantSummary } from "./issue-session-events.js";
|
|
|
14
14
|
import { WakeDispatcher } from "./wake-dispatcher.js";
|
|
15
15
|
import { CodexFollowupIntentClassifier } from "./followup-intent.js";
|
|
16
16
|
import { AgentInputService } from "./agent-input-service.js";
|
|
17
|
+
import { noopTelemetry } from "./telemetry.js";
|
|
17
18
|
export class WebhookHandler {
|
|
18
19
|
config;
|
|
19
20
|
db;
|
|
@@ -21,6 +22,7 @@ export class WebhookHandler {
|
|
|
21
22
|
codex;
|
|
22
23
|
logger;
|
|
23
24
|
feed;
|
|
25
|
+
telemetry;
|
|
24
26
|
installationHandler;
|
|
25
27
|
issueRemovalHandler;
|
|
26
28
|
commentWakeHandler;
|
|
@@ -30,20 +32,21 @@ export class WebhookHandler {
|
|
|
30
32
|
dependencyReadinessHandler;
|
|
31
33
|
linearSync;
|
|
32
34
|
wakeDispatcher;
|
|
33
|
-
constructor(config, db, linearProvider, codex, wakeDispatcherOrEnqueueIssue, logger, feed, followupClassifier, agentInput) {
|
|
35
|
+
constructor(config, db, linearProvider, codex, wakeDispatcherOrEnqueueIssue, logger, feed, followupClassifier, agentInput, telemetry = noopTelemetry) {
|
|
34
36
|
this.config = config;
|
|
35
37
|
this.db = db;
|
|
36
38
|
this.linearProvider = linearProvider;
|
|
37
39
|
this.codex = codex;
|
|
38
40
|
this.logger = logger;
|
|
39
41
|
this.feed = feed;
|
|
42
|
+
this.telemetry = telemetry;
|
|
40
43
|
// Webhook handlers never release leases — the orchestrator's
|
|
41
44
|
// run finalizer owns that. So when a test passes a bare
|
|
42
45
|
// enqueueIssue callback, wrap it in a dispatcher with a no-op
|
|
43
46
|
// releaseLease (any production caller passes a real dispatcher).
|
|
44
47
|
this.wakeDispatcher = wakeDispatcherOrEnqueueIssue instanceof WakeDispatcher
|
|
45
48
|
? wakeDispatcherOrEnqueueIssue
|
|
46
|
-
: new WakeDispatcher(db, wakeDispatcherOrEnqueueIssue, () => undefined, logger, feed);
|
|
49
|
+
: new WakeDispatcher(db, wakeDispatcherOrEnqueueIssue, () => undefined, logger, feed, telemetry);
|
|
47
50
|
this.installationHandler = new InstallationWebhookHandler(config, { linearInstallations: db.linearInstallations }, logger, feed);
|
|
48
51
|
this.issueRemovalHandler = new IssueRemovalHandler(db, feed);
|
|
49
52
|
this.linearSync = new LinearSessionSync(config, db, linearProvider, logger, feed);
|
|
@@ -53,7 +56,7 @@ export class WebhookHandler {
|
|
|
53
56
|
this.agentSessionHandler = new AgentSessionHandler(config, db, linearProvider, codex, this.wakeDispatcher, logger, feed, agentInputService);
|
|
54
57
|
this.desiredStageRecorder = new DesiredStageRecorder(db, linearProvider, this.wakeDispatcher, feed);
|
|
55
58
|
this.contextLoader = new WebhookContextLoader(config, linearProvider);
|
|
56
|
-
this.dependencyReadinessHandler = new DependencyReadinessHandler(db, this.wakeDispatcher, (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId));
|
|
59
|
+
this.dependencyReadinessHandler = new DependencyReadinessHandler(db, this.wakeDispatcher, (projectId, issueId) => this.peekPendingSessionWakeRunType(projectId, issueId), telemetry);
|
|
57
60
|
}
|
|
58
61
|
async processWebhookEvent(webhookEventId) {
|
|
59
62
|
const event = this.db.webhookEvents.getWebhookPayload(webhookEventId);
|
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { emitTelemetry, noopTelemetry } from "../telemetry.js";
|
|
1
2
|
export class DependencyReadinessHandler {
|
|
2
3
|
db;
|
|
3
4
|
wakeDispatcher;
|
|
4
5
|
peekPendingSessionWakeRunType;
|
|
5
|
-
|
|
6
|
+
telemetry;
|
|
7
|
+
constructor(db, wakeDispatcher, peekPendingSessionWakeRunType, telemetry = noopTelemetry) {
|
|
6
8
|
this.db = db;
|
|
7
9
|
this.wakeDispatcher = wakeDispatcher;
|
|
8
10
|
this.peekPendingSessionWakeRunType = peekPendingSessionWakeRunType;
|
|
11
|
+
this.telemetry = telemetry;
|
|
9
12
|
}
|
|
10
13
|
reconcile(projectId, blockerLinearIssueId) {
|
|
11
14
|
const newlyReady = [];
|
|
@@ -16,6 +19,25 @@ export class DependencyReadinessHandler {
|
|
|
16
19
|
}
|
|
17
20
|
const unresolved = this.db.issues.countUnresolvedBlockers(projectId, dependent.linearIssueId);
|
|
18
21
|
if (unresolved > 0) {
|
|
22
|
+
const blockerKeys = this.unresolvedBlockerKeys(projectId, dependent.linearIssueId);
|
|
23
|
+
emitTelemetry(this.telemetry, {
|
|
24
|
+
type: "dependency.remaining_blockers",
|
|
25
|
+
projectId,
|
|
26
|
+
linearIssueId: dependent.linearIssueId,
|
|
27
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
28
|
+
blockerLinearIssueId,
|
|
29
|
+
blockerCount: unresolved,
|
|
30
|
+
blockerKeys,
|
|
31
|
+
});
|
|
32
|
+
emitTelemetry(this.telemetry, {
|
|
33
|
+
type: "dependency.dependent_blocked",
|
|
34
|
+
projectId,
|
|
35
|
+
linearIssueId: dependent.linearIssueId,
|
|
36
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
37
|
+
blockerLinearIssueId,
|
|
38
|
+
blockerCount: unresolved,
|
|
39
|
+
blockerKeys,
|
|
40
|
+
});
|
|
19
41
|
if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation"
|
|
20
42
|
&& issue.activeRunId === undefined
|
|
21
43
|
&& !this.db.issueSessions.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
|
|
@@ -28,10 +50,25 @@ export class DependencyReadinessHandler {
|
|
|
28
50
|
}
|
|
29
51
|
continue;
|
|
30
52
|
}
|
|
31
|
-
if (issue.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
53
|
+
if (!issue.delegatedToPatchRelay || issue.activeRunId !== undefined) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const pendingWakeRunType = this.db.workflowWakes.peekIssueWake(projectId, dependent.linearIssueId)?.runType
|
|
57
|
+
?? issue.pendingRunType;
|
|
58
|
+
if (pendingWakeRunType) {
|
|
59
|
+
const dispatchedRunType = this.wakeDispatcher.dispatchIfWakePending(projectId, dependent.linearIssueId);
|
|
60
|
+
emitTelemetry(this.telemetry, {
|
|
61
|
+
type: "dependency.dependent_unblocked",
|
|
62
|
+
projectId,
|
|
63
|
+
linearIssueId: dependent.linearIssueId,
|
|
64
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
65
|
+
blockerLinearIssueId,
|
|
66
|
+
...(dispatchedRunType ? { dispatchedRunType } : {}),
|
|
67
|
+
});
|
|
68
|
+
newlyReady.push(dependent.linearIssueId);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (issue.factoryState !== "delegated" || this.db.issueSessions.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
|
|
35
72
|
continue;
|
|
36
73
|
}
|
|
37
74
|
if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation") {
|
|
@@ -42,12 +79,26 @@ export class DependencyReadinessHandler {
|
|
|
42
79
|
pendingRunContextJson: null,
|
|
43
80
|
});
|
|
44
81
|
}
|
|
45
|
-
this.wakeDispatcher.recordEventAndDispatch(projectId, dependent.linearIssueId, {
|
|
82
|
+
const dispatchedRunType = this.wakeDispatcher.recordEventAndDispatch(projectId, dependent.linearIssueId, {
|
|
46
83
|
eventType: "delegated",
|
|
47
84
|
dedupeKey: `delegated:${dependent.linearIssueId}`,
|
|
48
85
|
});
|
|
86
|
+
emitTelemetry(this.telemetry, {
|
|
87
|
+
type: "dependency.dependent_unblocked",
|
|
88
|
+
projectId,
|
|
89
|
+
linearIssueId: dependent.linearIssueId,
|
|
90
|
+
...(issue.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
91
|
+
blockerLinearIssueId,
|
|
92
|
+
...(dispatchedRunType ? { dispatchedRunType } : {}),
|
|
93
|
+
});
|
|
49
94
|
newlyReady.push(dependent.linearIssueId);
|
|
50
95
|
}
|
|
51
96
|
return newlyReady;
|
|
52
97
|
}
|
|
98
|
+
unresolvedBlockerKeys(projectId, linearIssueId) {
|
|
99
|
+
return this.db.issues.listIssueDependencies(projectId, linearIssueId)
|
|
100
|
+
.filter((entry) => entry.blockerCurrentLinearStateType !== "completed"
|
|
101
|
+
&& entry.blockerCurrentLinearState?.trim().toLowerCase() !== "done")
|
|
102
|
+
.map((entry) => entry.blockerIssueKey ?? entry.blockerLinearIssueId);
|
|
103
|
+
}
|
|
53
104
|
}
|
|
@@ -85,12 +85,14 @@ export class WorkflowWakeResolver {
|
|
|
85
85
|
this.issueSessions = issueSessions;
|
|
86
86
|
}
|
|
87
87
|
peekIssueWake(projectId, linearIssueId) {
|
|
88
|
-
const explicitWake = this.issueSessions.peekIssueSessionWake(projectId, linearIssueId);
|
|
89
|
-
if (explicitWake)
|
|
90
|
-
return explicitWake;
|
|
91
88
|
const issue = this.issues.getIssue(projectId, linearIssueId);
|
|
92
89
|
if (!issue)
|
|
93
90
|
return undefined;
|
|
91
|
+
if (this.issues.countUnresolvedBlockers(projectId, linearIssueId) > 0)
|
|
92
|
+
return undefined;
|
|
93
|
+
const explicitWake = this.issueSessions.peekIssueSessionWake(projectId, linearIssueId);
|
|
94
|
+
if (explicitWake)
|
|
95
|
+
return explicitWake;
|
|
94
96
|
const implicitWake = deriveImplicitReactiveWake(issue);
|
|
95
97
|
if (!implicitWake)
|
|
96
98
|
return undefined;
|