patchrelay 0.68.7 → 0.69.1

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.
@@ -31,6 +31,7 @@ export function isPatchRelayGeneratedActivityComment(body) {
31
31
  || body.startsWith("PatchRelay is already working on ")
32
32
  || body.startsWith("PatchRelay received the ")
33
33
  || body.startsWith("PatchRelay routed your latest instructions into ")
34
+ || body.startsWith("PatchRelay could not route your latest instructions into ")
34
35
  || body.startsWith("PatchRelay status:")
35
36
  || body.startsWith("PatchRelay did not start implementation ")
36
37
  || body.startsWith("PatchRelay has stopped work as requested.")
@@ -38,5 +39,22 @@ export function isPatchRelayGeneratedActivityComment(body) {
38
39
  || body === "This thread is for an agent session with patchrelay.";
39
40
  }
40
41
  export function hasExplicitPatchRelayWakeIntent(body) {
41
- return /\bpatchrelay\b/i.test(body);
42
+ return extractPatchRelayAddressedText(body) !== undefined;
43
+ }
44
+ export function extractPatchRelayAddressedText(body) {
45
+ const trimmed = body.trim();
46
+ if (!trimmed)
47
+ return undefined;
48
+ const patterns = [
49
+ /^@?patchrelay\b[\s,:;-]*/i,
50
+ /^\bhey\s+@?patchrelay\b[\s,:;-]*/i,
51
+ ];
52
+ for (const pattern of patterns) {
53
+ const match = trimmed.match(pattern);
54
+ if (!match)
55
+ continue;
56
+ const rest = trimmed.slice(match[0].length).trim();
57
+ return rest || trimmed;
58
+ }
59
+ return undefined;
42
60
  }
@@ -1,20 +1,17 @@
1
- import { classifyFollowupIntent, followupIntentIsNonActionable, followupIntentQueuesWork } from "../followup-intent.js";
2
1
  import { triggerEventAllowed } from "../project-resolution.js";
3
- import { hasExplicitPatchRelayWakeIntent, isInertPatchRelayComment, isPatchRelayManagedCommentAuthor, } from "./comment-policy.js";
4
- import { classifyIssue } from "../issue-class.js";
5
- const ENQUEUEABLE_STATES = new Set(["pr_open", "changes_requested", "implementing", "delegated", "awaiting_input"]);
2
+ import { extractPatchRelayAddressedText, isInertPatchRelayComment, isPatchRelayManagedCommentAuthor, } from "./comment-policy.js";
6
3
  export class CommentWakeHandler {
7
4
  db;
8
- codex;
9
5
  wakeDispatcher;
10
- logger;
11
6
  feed;
12
- constructor(db, codex, wakeDispatcher, logger, feed) {
7
+ conversationAdapter;
8
+ emitLinearActivity;
9
+ constructor(db, wakeDispatcher, feed, conversationAdapter, emitLinearActivity) {
13
10
  this.db = db;
14
- this.codex = codex;
15
11
  this.wakeDispatcher = wakeDispatcher;
16
- this.logger = logger;
17
12
  this.feed = feed;
13
+ this.conversationAdapter = conversationAdapter;
14
+ this.emitLinearActivity = emitLinearActivity;
18
15
  }
19
16
  async handle(params) {
20
17
  const { normalized, project, trackedIssue } = params;
@@ -28,10 +25,6 @@ export class CommentWakeHandler {
28
25
  const issue = this.db.issues.getIssue(project.id, normalized.issue.id);
29
26
  if (!issue)
30
27
  return;
31
- const issueClass = classifyIssue({
32
- issue,
33
- childIssueCount: this.db.issues.listCanonicalChildIssues(project.id, normalized.issue.id).length,
34
- }).issueClass;
35
28
  const trimmedBody = normalized.comment.body.trim();
36
29
  const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
37
30
  const selfAuthored = isPatchRelayManagedCommentAuthor(installation, normalized.actor, normalized.comment.userName);
@@ -58,168 +51,39 @@ export class CommentWakeHandler {
58
51
  });
59
52
  return;
60
53
  }
61
- const directReply = params.isDirectReplyToOutstandingQuestion(issue);
62
- const intent = classifyFollowupIntent(trimmedBody);
63
- if (!issue.activeRunId) {
64
- if (ENQUEUEABLE_STATES.has(issue.factoryState)) {
65
- const wakeIntent = issueClass === "orchestration" || directReply || hasExplicitPatchRelayWakeIntent(trimmedBody);
66
- if (!wakeIntent) {
67
- this.feed?.publish({
68
- level: "info",
69
- kind: "comment",
70
- projectId: project.id,
71
- issueKey: trackedIssue?.issueKey,
72
- status: "ignored",
73
- summary: "Ignored comment with no explicit PatchRelay wake intent",
74
- detail: trimmedBody.slice(0, 200),
75
- });
76
- return;
77
- }
78
- if (intent === "stop") {
79
- this.wakeDispatcher.recordEventAndDispatch(project.id, normalized.issue.id, {
80
- eventType: "stop_requested",
81
- eventJson: JSON.stringify({
82
- body: trimmedBody,
83
- author: normalized.comment.userName,
84
- }),
85
- });
86
- this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(project.id, normalized.issue.id);
87
- this.feed?.publish({
88
- level: "info",
89
- kind: "comment",
90
- projectId: project.id,
91
- issueKey: trackedIssue?.issueKey,
92
- status: "stopped",
93
- summary: "Stop request recorded from Linear comment",
94
- detail: trimmedBody.slice(0, 200),
95
- });
96
- return;
97
- }
98
- if (!directReply && !followupIntentQueuesWork(intent)) {
99
- this.feed?.publish({
100
- level: "info",
101
- kind: "comment",
102
- projectId: project.id,
103
- issueKey: trackedIssue?.issueKey,
104
- status: intent === "status" ? "status_requested" : "ignored",
105
- summary: intent === "status"
106
- ? "Ignored status comment without queueing work"
107
- : "Ignored non-actionable follow-up comment",
108
- detail: trimmedBody.slice(0, 200),
109
- });
110
- return;
111
- }
112
- const runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
113
- const queuedRunType = this.wakeDispatcher.recordEventAndDispatch(project.id, normalized.issue.id, {
114
- eventType: directReply ? "direct_reply" : "followup_comment",
115
- eventJson: JSON.stringify({
116
- body: trimmedBody,
117
- author: normalized.comment.userName,
118
- }),
119
- });
120
- this.feed?.publish({
121
- level: "info",
122
- kind: "comment",
123
- projectId: project.id,
124
- issueKey: trackedIssue?.issueKey,
125
- status: "enqueued",
126
- summary: `Comment enqueued ${(queuedRunType ?? runType)} run`,
127
- detail: trimmedBody.slice(0, 200),
128
- });
129
- }
130
- return;
131
- }
132
- const run = this.db.runs.getRunById(issue.activeRunId);
133
- if (!run?.threadId || !run.turnId)
134
- return;
135
- if (intent === "stop") {
136
- try {
137
- await this.codex.steerTurn({
138
- threadId: run.threadId,
139
- turnId: run.turnId,
140
- input: "STOP: The user has requested you stop working immediately. Do not make further changes. Wrap up and exit.",
141
- });
142
- }
143
- catch (error) {
144
- this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for comment stop request");
145
- }
146
- this.db.runs.finishRun(run.id, { status: "released", threadId: run.threadId, turnId: run.turnId });
147
- this.db.issueSessions.upsertIssueRespectingActiveLease(project.id, normalized.issue.id, {
148
- projectId: project.id,
149
- linearIssueId: normalized.issue.id,
150
- activeRunId: null,
151
- factoryState: "awaiting_input",
152
- });
153
- this.wakeDispatcher.recordEventAndDispatch(project.id, normalized.issue.id, {
154
- eventType: "stop_requested",
155
- eventJson: JSON.stringify({
156
- body: trimmedBody,
157
- author: normalized.comment.userName,
158
- }),
159
- });
160
- this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(project.id, normalized.issue.id);
54
+ const addressedText = extractPatchRelayAddressedText(trimmedBody);
55
+ if (!addressedText) {
161
56
  this.feed?.publish({
162
57
  level: "info",
163
58
  kind: "comment",
164
59
  projectId: project.id,
165
60
  issueKey: trackedIssue?.issueKey,
166
- stage: run.runType,
167
- status: "stopped",
168
- summary: "Stop request delivered to active workflow",
169
- });
170
- return;
171
- }
172
- if (!directReply && followupIntentIsNonActionable(intent)) {
173
- this.feed?.publish({
174
- level: "info",
175
- kind: "comment",
176
- projectId: project.id,
177
- issueKey: trackedIssue?.issueKey,
178
- stage: run.runType,
179
- status: intent === "status" ? "status_requested" : "ignored",
180
- summary: intent === "status"
181
- ? "Ignored status comment without steering active workflow"
182
- : "Ignored non-actionable follow-up comment",
61
+ status: "ignored",
62
+ summary: "Ignored issue comment because it did not address PatchRelay",
183
63
  detail: trimmedBody.slice(0, 200),
184
64
  });
185
65
  return;
186
66
  }
187
- const body = [
188
- "New Linear comment received while you are working.",
189
- normalized.comment.userName ? `Author: ${normalized.comment.userName}` : undefined,
190
- "",
191
- trimmedBody,
192
- ].filter(Boolean).join("\n");
193
- try {
194
- await this.codex.steerTurn({ threadId: run.threadId, turnId: run.turnId, input: body });
67
+ const result = await this.conversationAdapter?.deliverAgentInput({
68
+ project,
69
+ issue,
70
+ source: "addressed_issue_comment",
71
+ body: addressedText,
72
+ author: normalized.comment.userName,
73
+ directReply: params.isDirectReplyToOutstandingQuestion(issue),
74
+ emitActivity: this.emitLinearActivity
75
+ ? (content, options) => this.emitLinearActivity(issue, content, options)
76
+ : undefined,
77
+ });
78
+ if (result?.queuedRunType) {
195
79
  this.feed?.publish({
196
80
  level: "info",
197
81
  kind: "comment",
198
82
  projectId: project.id,
199
83
  issueKey: trackedIssue?.issueKey,
200
- stage: run.runType,
201
- status: "delivered",
202
- summary: `Delivered follow-up comment to active ${run.runType} workflow`,
203
- });
204
- }
205
- catch (error) {
206
- this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to deliver follow-up comment");
207
- const directReply = params.isDirectReplyToOutstandingQuestion(issue);
208
- this.wakeDispatcher.recordEventAndDispatch(project.id, normalized.issue.id, {
209
- eventType: directReply ? "direct_reply" : "followup_comment",
210
- eventJson: JSON.stringify({
211
- body: trimmedBody,
212
- author: normalized.comment.userName,
213
- }),
214
- });
215
- this.feed?.publish({
216
- level: "warn",
217
- kind: "comment",
218
- projectId: project.id,
219
- issueKey: trackedIssue?.issueKey,
220
- stage: run.runType,
221
- status: "delivery_failed",
222
- summary: `Could not deliver follow-up comment to active ${run.runType} workflow`,
84
+ status: "enqueued",
85
+ summary: `Comment enqueued ${result.queuedRunType} run`,
86
+ detail: addressedText.slice(0, 200),
223
87
  });
224
88
  }
225
89
  }
@@ -25,13 +25,13 @@ Environment=PATH=/home/your-user/.local/share/patchrelay/bin:/home/your-user/.lo
25
25
  #
26
26
  # When credstore is not configured, PatchRelay falls back to reading secrets
27
27
  # from env vars (EnvironmentFile, op run, etc.) via the resolve-secret module.
28
- LoadCredentialEncrypted=linear-webhook-secret
29
- LoadCredentialEncrypted=token-encryption-key
30
- LoadCredentialEncrypted=linear-oauth-client-id
31
- LoadCredentialEncrypted=linear-oauth-client-secret
28
+ LoadCredentialEncrypted=linear-webhook-secret:/etc/credstore.encrypted/linear-webhook-secret.cred
29
+ LoadCredentialEncrypted=token-encryption-key:/etc/credstore.encrypted/token-encryption-key.cred
30
+ LoadCredentialEncrypted=linear-oauth-client-id:/etc/credstore.encrypted/linear-oauth-client-id.cred
31
+ LoadCredentialEncrypted=linear-oauth-client-secret:/etc/credstore.encrypted/linear-oauth-client-secret.cred
32
32
  # Uncomment when GitHub App integration is configured:
33
- # LoadCredentialEncrypted=github-app-pem
34
- # LoadCredentialEncrypted=github-app-webhook-secret
33
+ # LoadCredentialEncrypted=github-app-pem:/etc/credstore.encrypted/github-app-pem.cred
34
+ # LoadCredentialEncrypted=github-app-webhook-secret:/etc/credstore.encrypted/github-app-webhook-secret.cred
35
35
 
36
36
  ExecStart=/usr/bin/env patchrelay serve
37
37
  Restart=on-failure
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.68.7",
3
+ "version": "0.69.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {