patchrelay 0.74.6 → 0.74.8

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.8",
4
+ "commit": "5016ef557bde",
5
+ "builtAt": "2026-05-29T12:02:10.583Z"
6
6
  }
@@ -1,7 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  const CODEX_STATUS_TIMEOUT_MS = 15_000;
3
3
  function stripAnsiCodes(value) {
4
- return value.replace(/\u001b\[[0-9;]*m/g, "");
4
+ const escape = String.fromCharCode(27);
5
+ return value.replace(new RegExp(`${escape}\\[[0-9;]*m`, "g"), "");
5
6
  }
6
7
  function parseAccountLine(output) {
7
8
  const lines = output.split(/\r?\n/);
@@ -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();
package/dist/index.js CHANGED
@@ -1,7 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import { dirname } from "node:path";
3
3
  import { runCli } from "./cli/index.js";
4
+ function suppressNodeSqliteExperimentalWarning() {
5
+ const originalEmitWarning = process.emitWarning;
6
+ process.emitWarning = function emitWarningWithoutNodeSqliteNoise(warning, optionsOrType, codeOrCtor, ctor) {
7
+ const message = warning instanceof Error ? warning.message : warning;
8
+ const type = typeof optionsOrType === "string" ? optionsOrType : optionsOrType?.type;
9
+ if (type === "ExperimentalWarning" && message.includes("SQLite is an experimental feature")) {
10
+ return;
11
+ }
12
+ return originalEmitWarning.call(process, warning, optionsOrType, codeOrCtor, ctor);
13
+ };
14
+ }
4
15
  async function main() {
16
+ suppressNodeSqliteExperimentalWarning();
5
17
  const cliExitCode = await runCli(process.argv.slice(2));
6
18
  if (cliExitCode !== -1) {
7
19
  process.exitCode = cliExitCode;
@@ -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/preflight.js CHANGED
@@ -117,6 +117,9 @@ async function checkLinearApi(graphqlUrl) {
117
117
  if (response.ok) {
118
118
  return pass("linear_api", `Linear GraphQL API is reachable at ${graphqlUrl}`);
119
119
  }
120
+ if (response.status === 401 || response.status === 403) {
121
+ return pass("linear_api", `Linear GraphQL API is reachable at ${graphqlUrl} (authentication required)`);
122
+ }
120
123
  return warn("linear_api", `Linear GraphQL API returned ${response.status} — may be unreachable or rate-limited`);
121
124
  }
122
125
  catch (error) {