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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.74.6",
4
- "commit": "4c5efe0939d1",
5
- "builtAt": "2026-05-29T08:45:52.226Z"
3
+ "version": "0.74.7",
4
+ "commit": "53cd46e337f8",
5
+ "builtAt": "2026-05-29T11:49:02.626Z"
6
6
  }
@@ -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
- constructor(connection, mapIssueSessionRow, mapIssueSessionEventRow, issues, runs) {
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
- return this.getIssueSessionEvent(Number(result.lastInsertRowid));
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 })
@@ -10,10 +10,10 @@ const OPEN_CHILD_PREDICATE = `
10
10
  `;
11
11
  export class IssueStore {
12
12
  connection;
13
- syncIssueSessionFromIssue;
14
- constructor(connection, syncIssueSessionFromIssue) {
13
+ issueSessionProjection;
14
+ constructor(connection, issueSessionProjection) {
15
15
  this.connection = connection;
16
- this.syncIssueSessionFromIssue = syncIssueSessionFromIssue;
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.syncIssueSessionFromIssue(updated);
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) {
@@ -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
- syncIssueSessionFromIssue;
8
- constructor(connection, mapRunRow, issues, syncIssueSessionFromIssue) {
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.syncIssueSessionFromIssue = syncIssueSessionFromIssue;
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.syncIssueSessionFromIssue(issue, { lastRunType: run.runType });
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.syncIssueSessionFromIssue(issue);
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.syncIssueSessionFromIssue(issue, {
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.syncIssueSessionFromIssue(issue, {
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.syncIssueSessionFromIssue(issue, {
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
- this.issues = new IssueStore(this.connection, (issue) => syncIssueSessionFromIssue({ connection: this.connection, issues: this.issues, issueSessions: this.issueSessions, runs: this.runs, issue }));
38
- this.runs = new RunStore(this.connection, mapRunRow, this.issues, (issue, options) => syncIssueSessionFromIssue({
39
- connection: this.connection,
40
- issues: this.issues,
41
- issueSessions: this.issueSessions,
42
- runs: this.runs,
43
- issue,
44
- ...(options ? { options } : {}),
45
- }));
46
- this.issueSessions = new IssueSessionStore(this.connection, mapIssueSessionRow, mapIssueSessionEventRow, this.issues, this.runs);
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
+ }
@@ -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
- constructor(config, db, codex, linearProvider, enqueueIssue, wakeDispatcherOrLogger, loggerOrFeed, feedOrConfigPath, configPathOrUndefined) {
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
+ }
@@ -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?.activeRunId !== undefined)
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?.pendingRunType;
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
  }
@@ -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
- constructor(db, wakeDispatcher, peekPendingSessionWakeRunType) {
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.factoryState !== "delegated"
32
- || !issue.delegatedToPatchRelay
33
- || issue.activeRunId !== undefined
34
- || this.db.issueSessions.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.74.6",
3
+ "version": "0.74.7",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {