patchrelay 0.56.0 → 0.57.0

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.56.0",
4
- "commit": "43aa8a020a26",
5
- "builtAt": "2026-05-01T14:26:54.243Z"
3
+ "version": "0.57.0",
4
+ "commit": "d67134a3b377",
5
+ "builtAt": "2026-05-01T22:45:14.762Z"
6
6
  }
@@ -0,0 +1,56 @@
1
+ const IMPLEMENTATION_PATTERNS = [
2
+ /\b(add|address|adjust|change|create|delete|deploy|fix|implement|install|merge|open|polish|publish|push|remove|rename|set|ship|update)\b/i,
3
+ /\b(go on and|go ahead and|please|pls)\s+\b(add|address|adjust|change|create|delete|deploy|fix|implement|install|merge|open|polish|publish|push|remove|rename|set|ship|update|use)\b/i,
4
+ /\b(use|keep|switch|move)\b.+\b(instead|copy|api|contract|behavior|state|repo|branch|team|issue|pr)\b/i,
5
+ ];
6
+ const RETRY_PATTERNS = [
7
+ /\b(continue|go on|keep going|proceed|resume)\b/i,
8
+ /\b(retry|try again|rerun|run again|restart)\b/i,
9
+ /\b(next task|next issue)\b/i,
10
+ ];
11
+ const STOP_PATTERNS = [
12
+ /\b(stop|cancel|halt|abort)\b/i,
13
+ /\b(pause|hold)\s+(work|implementation|the run|patchrelay)\b/i,
14
+ /\bdo not\s+(continue|proceed|work|implement)\b/i,
15
+ ];
16
+ const STATUS_PATTERNS = [
17
+ /\b(status|progress)\b/i,
18
+ /\b(status update|progress update|any update|quick update)\b/i,
19
+ /\b(where are we|what'?s happening|what is happening|what'?s deployed|what is deployed)\b/i,
20
+ /\b(done so far|deployed so far|current work|current run)\b/i,
21
+ ];
22
+ const CLARIFICATION_PATTERNS = [
23
+ /\b(fyi|for context|heads up|to clarify|clarification|correction|actually|note that)\b/i,
24
+ /\b(i meant|what i meant|not asking you to|no action needed)\b/i,
25
+ ];
26
+ const QUESTION_PATTERNS = [
27
+ /\?$/,
28
+ /\b(can|could|do|does|did|is|are|was|were|why|how|what|when|where|which|who|should|would)\b/i,
29
+ ];
30
+ export function classifyFollowupIntent(input) {
31
+ const text = input.trim();
32
+ if (!text)
33
+ return "clarification";
34
+ if (matchesAny(text, STOP_PATTERNS))
35
+ return "stop";
36
+ if (matchesAny(text, RETRY_PATTERNS))
37
+ return "retry";
38
+ if (matchesAny(text, STATUS_PATTERNS))
39
+ return "status";
40
+ if (matchesAny(text, IMPLEMENTATION_PATTERNS))
41
+ return "implementation_request";
42
+ if (matchesAny(text, CLARIFICATION_PATTERNS))
43
+ return "clarification";
44
+ if (matchesAny(text, QUESTION_PATTERNS))
45
+ return "question";
46
+ return "clarification";
47
+ }
48
+ export function followupIntentQueuesWork(intent) {
49
+ return intent === "implementation_request" || intent === "retry";
50
+ }
51
+ export function followupIntentIsNonActionable(intent) {
52
+ return intent === "status" || intent === "question" || intent === "clarification";
53
+ }
54
+ function matchesAny(text, patterns) {
55
+ return patterns.some((pattern) => pattern.test(text));
56
+ }
@@ -0,0 +1,83 @@
1
+ const ACTIVITY_RECOVERY_LIMIT = 20;
2
+ const MAX_CONTEXT_ACTIVITIES = 8;
3
+ const MAX_ACTIVITY_TEXT_LENGTH = 500;
4
+ function trimBounded(value, maxLength = MAX_ACTIVITY_TEXT_LENGTH) {
5
+ const normalized = value.replace(/\s+/g, " ").trim();
6
+ if (normalized.length <= maxLength)
7
+ return normalized;
8
+ return `${normalized.slice(0, maxLength - 1).trimEnd()}...`;
9
+ }
10
+ function hasRecoveredContext(context) {
11
+ return typeof context?.linearAgentActivityContext === "string" && context.linearAgentActivityContext.trim().length > 0;
12
+ }
13
+ function hasLocalHumanContext(context) {
14
+ if (hasRecoveredContext(context))
15
+ return true;
16
+ for (const key of ["promptContext", "promptBody", "operatorPrompt", "userComment"]) {
17
+ const value = context?.[key];
18
+ if (typeof value === "string" && value.trim().length > 0)
19
+ return true;
20
+ }
21
+ if (!Array.isArray(context?.followUps))
22
+ return false;
23
+ return context.followUps.some((entry) => {
24
+ if (!entry || typeof entry !== "object")
25
+ return false;
26
+ const text = entry.text;
27
+ return typeof text === "string" && text.trim().length > 0;
28
+ });
29
+ }
30
+ function activitySortKey(activity) {
31
+ const parsed = activity.updatedAt ? Date.parse(activity.updatedAt) : NaN;
32
+ return Number.isFinite(parsed) ? parsed : 0;
33
+ }
34
+ function describeActivity(activity) {
35
+ const type = activity.type?.trim() || "activity";
36
+ const body = typeof activity.body === "string" ? trimBounded(activity.body) : "";
37
+ if (body) {
38
+ return `${type}: ${body}`;
39
+ }
40
+ if (activity.action || activity.parameter || activity.result) {
41
+ const action = activity.action ? trimBounded(activity.action, 120) : "action";
42
+ const parameter = activity.parameter ? ` ${trimBounded(activity.parameter, 180)}` : "";
43
+ const result = activity.result ? ` -> ${trimBounded(activity.result, 180)}` : "";
44
+ return `${type}: ${action}${parameter}${result}`;
45
+ }
46
+ return undefined;
47
+ }
48
+ export function summarizeLinearAgentActivities(activities) {
49
+ const lines = [...activities]
50
+ .sort((left, right) => activitySortKey(left) - activitySortKey(right))
51
+ .map(describeActivity)
52
+ .filter((line) => Boolean(line))
53
+ .slice(-MAX_CONTEXT_ACTIVITIES);
54
+ if (lines.length === 0)
55
+ return undefined;
56
+ return {
57
+ linearAgentActivityContext: lines.map((line) => `- ${line}`).join("\n"),
58
+ linearAgentActivityCount: lines.length,
59
+ };
60
+ }
61
+ export async function recoverLinearAgentActivityContext(params) {
62
+ if (!params.agentSessionId || hasLocalHumanContext(params.context)) {
63
+ return undefined;
64
+ }
65
+ try {
66
+ const linear = await params.linearProvider.forProject(params.projectId);
67
+ if (!linear?.listAgentSessionActivities) {
68
+ return undefined;
69
+ }
70
+ const activities = await linear.listAgentSessionActivities(params.agentSessionId, {
71
+ first: ACTIVITY_RECOVERY_LIMIT,
72
+ });
73
+ return summarizeLinearAgentActivities(activities);
74
+ }
75
+ catch (error) {
76
+ params.logger.warn({
77
+ issueKey: params.issueKey,
78
+ agentSessionId: params.agentSessionId,
79
+ error: error instanceof Error ? error.message : String(error),
80
+ }, "Failed to recover Linear agent activity context");
81
+ return undefined;
82
+ }
83
+ }
@@ -241,6 +241,55 @@ export class LinearGraphqlClient {
241
241
  }
242
242
  return response.agentSessionUpdate.agentSession;
243
243
  }
244
+ async listAgentSessionActivities(agentSessionId, options) {
245
+ const response = await this.request(`
246
+ query PatchRelayAgentSessionActivities($id: String!, $first: Int!) {
247
+ agentSession(id: $id) {
248
+ activities(first: $first) {
249
+ edges {
250
+ node {
251
+ id
252
+ updatedAt
253
+ content {
254
+ __typename
255
+ ... on AgentActivityThoughtContent {
256
+ body
257
+ }
258
+ ... on AgentActivityActionContent {
259
+ action
260
+ parameter
261
+ result
262
+ }
263
+ ... on AgentActivityElicitationContent {
264
+ body
265
+ }
266
+ ... on AgentActivityResponseContent {
267
+ body
268
+ }
269
+ ... on AgentActivityErrorContent {
270
+ body
271
+ }
272
+ ... on AgentActivityPromptContent {
273
+ body
274
+ }
275
+ }
276
+ }
277
+ }
278
+ }
279
+ }
280
+ }
281
+ `, {
282
+ id: agentSessionId,
283
+ first: Math.max(1, Math.min(options?.first ?? 20, 50)),
284
+ });
285
+ const activityEdges = response.agentSession?.activities?.edges;
286
+ const rawActivities = activityEdges
287
+ ? activityEdges.map((edge) => edge?.node)
288
+ : response.agentSession?.activities?.nodes ?? [];
289
+ return rawActivities
290
+ .filter((activity) => Boolean(activity))
291
+ .map(mapAgentActivity);
292
+ }
244
293
  async updateIssueLabels(params) {
245
294
  const issue = await this.getIssue(params.issueId);
246
295
  const addIds = this.resolveLabelIds(issue, params.addNames ?? []);
@@ -439,6 +488,25 @@ function mapIssueRelation(raw) {
439
488
  ...(raw.state?.type ? { stateType: raw.state.type } : {}),
440
489
  };
441
490
  }
491
+ function mapAgentActivity(raw) {
492
+ const content = raw.content ?? {};
493
+ return {
494
+ id: raw.id,
495
+ ...(content.__typename ? { type: normalizeAgentActivityType(content.__typename) } : {}),
496
+ ...(content.body ? { body: content.body } : {}),
497
+ ...(content.action ? { action: content.action } : {}),
498
+ ...(content.parameter ? { parameter: content.parameter } : {}),
499
+ ...(content.result ? { result: content.result } : {}),
500
+ ...(raw.updatedAt ? { updatedAt: raw.updatedAt } : {}),
501
+ };
502
+ }
503
+ function normalizeAgentActivityType(typename) {
504
+ return typename
505
+ .replace(/^AgentActivity/, "")
506
+ .replace(/Content$/, "")
507
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
508
+ .toLowerCase();
509
+ }
442
510
  export class DatabaseBackedLinearClientProvider {
443
511
  config;
444
512
  db;
@@ -64,6 +64,24 @@ export function buildPromptDeliveredThought(runType) {
64
64
  body: `PatchRelay routed your latest instructions into the active ${lowerRunTypeLabel(runType)} workflow.`,
65
65
  };
66
66
  }
67
+ export function buildFollowupStatusActivity(params) {
68
+ const subject = params.issue.issueKey ? `${params.issue.issueKey}` : "this issue";
69
+ const runNote = params.activeRunType
70
+ ? ` Active workflow: ${lowerRunTypeLabel(params.activeRunType)}.`
71
+ : params.pendingRunType ? ` Queued workflow: ${lowerRunTypeLabel(params.pendingRunType)}.` : "";
72
+ const prNote = params.issue.prNumber ? ` PR #${params.issue.prNumber}.` : "";
73
+ const statusNote = params.statusNote ? ` ${params.statusNote}` : "";
74
+ return {
75
+ type: "response",
76
+ body: `PatchRelay status: ${subject} is ${formatFactoryState(params.issue.factoryState)}.${prNote}${runNote}${statusNote}`.trim(),
77
+ };
78
+ }
79
+ export function buildNonActionableFollowupActivity(intent) {
80
+ const body = intent === "status"
81
+ ? "PatchRelay status is available in the current agent session."
82
+ : "PatchRelay did not start implementation because this looks like a question or clarification. Ask PatchRelay to continue, retry, or implement when you want work to run.";
83
+ return { type: "response", body };
84
+ }
67
85
  export function buildRunStartedActivity(runType) {
68
86
  switch (runType) {
69
87
  case "review_fix":
@@ -79,6 +97,9 @@ export function buildRunStartedActivity(runType) {
79
97
  return { type: "action", action: "Implementing", parameter: "requested change" };
80
98
  }
81
99
  }
100
+ function formatFactoryState(state) {
101
+ return state.replaceAll("_", " ");
102
+ }
82
103
  export function buildRunCompletedActivity(params) {
83
104
  const prLabel = params.prNumber ? `PR #${params.prNumber}` : "the pull request";
84
105
  const summary = trimSummary(params.completionSummary);
@@ -197,6 +197,9 @@ function buildHumanContextLines(context) {
197
197
  const latestPrompt = typeof context?.promptBody === "string" ? context.promptBody.trim() : "";
198
198
  const operatorPrompt = typeof context?.operatorPrompt === "string" ? context.operatorPrompt.trim() : "";
199
199
  const userComment = typeof context?.userComment === "string" ? context.userComment.trim() : "";
200
+ const linearAgentActivityContext = typeof context?.linearAgentActivityContext === "string"
201
+ ? context.linearAgentActivityContext.trim()
202
+ : "";
200
203
  const lines = [];
201
204
  if (promptContext) {
202
205
  lines.push("Linear session context:", promptContext, "");
@@ -210,6 +213,9 @@ function buildHumanContextLines(context) {
210
213
  if (userComment) {
211
214
  lines.push("Human follow-up comment:", userComment, "");
212
215
  }
216
+ if (linearAgentActivityContext) {
217
+ lines.push("Recovered Linear agent activity context:", linearAgentActivityContext, "");
218
+ }
213
219
  return lines;
214
220
  }
215
221
  function resolveRequestedChangesMode(runType, context) {
@@ -8,6 +8,7 @@ import { MainBranchHealthMonitor } from "./main-branch-health-monitor.js";
8
8
  import { QueueHealthMonitor } from "./queue-health-monitor.js";
9
9
  import { IdleIssueReconciler } from "./idle-reconciliation.js";
10
10
  import { LinearSessionSync } from "./linear-session-sync.js";
11
+ import { recoverLinearAgentActivityContext } from "./linear-agent-activity-recovery.js";
11
12
  import { IssueSessionLeaseService } from "./issue-session-lease-service.js";
12
13
  import { InterruptedRunRecovery } from "./interrupted-run-recovery.js";
13
14
  import { RunCompletionPolicy } from "./run-completion-policy.js";
@@ -234,12 +235,23 @@ export class RunOrchestrator {
234
235
  const baseContext = isRequestedChangesRunType(runType)
235
236
  ? await this.runCompletionPolicy.resolveRequestedChangesWakeContext(issue, runType, context)
236
237
  : context;
238
+ const recoveredLinearActivityContext = await recoverLinearAgentActivityContext({
239
+ linearProvider: this.linearProvider,
240
+ projectId: issue.projectId,
241
+ agentSessionId: issue.agentSessionId,
242
+ context: baseContext,
243
+ issueKey: issue.issueKey,
244
+ logger: this.logger,
245
+ });
246
+ const baseContextWithRecoveredActivity = recoveredLinearActivityContext
247
+ ? { ...baseContext, ...recoveredLinearActivityContext }
248
+ : baseContext;
237
249
  const coordinationContext = runType === "implementation"
238
250
  ? this.buildRelatedIssueContext(issue)
239
251
  : undefined;
240
252
  const effectiveContext = coordinationContext
241
- ? { ...coordinationContext, ...(baseContext ?? {}) }
242
- : baseContext;
253
+ ? { ...coordinationContext, ...baseContextWithRecoveredActivity }
254
+ : baseContextWithRecoveredActivity;
243
255
  const sourceHeadSha = typeof effectiveContext?.failureHeadSha === "string"
244
256
  ? effectiveContext.failureHeadSha
245
257
  : typeof effectiveContext?.headSha === "string"
@@ -1,7 +1,10 @@
1
1
  import { buildAgentSessionPlanForIssue, } from "../agent-session-plan.js";
2
2
  import { buildAgentSessionExternalUrls } from "../agent-session-presentation.js";
3
- import { buildAlreadyRunningThought, buildAgentSessionAcknowledgementThought, buildBlockedDelegationActivity, buildDelegationThought, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "../linear-session-reporting.js";
3
+ import { classifyFollowupIntent, followupIntentIsNonActionable } from "../followup-intent.js";
4
+ import { extractLatestAssistantSummary } from "../issue-session-events.js";
5
+ import { buildAlreadyRunningThought, buildAgentSessionAcknowledgementThought, buildBlockedDelegationActivity, buildDelegationThought, buildFollowupStatusActivity, buildNonActionableFollowupActivity, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "../linear-session-reporting.js";
4
6
  import { resolveProject, triggerEventAllowed } from "../project-resolution.js";
7
+ import { deriveIssueStatusNote } from "../status-note.js";
5
8
  const PATCHRELAY_AGENT_ACTIVITY_TYPES = new Set([
6
9
  "action",
7
10
  "elicitation",
@@ -122,6 +125,28 @@ export class AgentSessionHandler {
122
125
  return;
123
126
  }
124
127
  const promptBody = normalized.agentSession.promptBody?.trim();
128
+ const directReply = promptBody && existingIssue ? params.isDirectReplyToOutstandingQuestion(existingIssue) : false;
129
+ const promptIntent = promptBody ? classifyFollowupIntent(promptBody) : undefined;
130
+ if (promptBody && existingIssue && promptIntent === "stop") {
131
+ await this.handleStopSignal({
132
+ normalized,
133
+ project,
134
+ trackedIssue,
135
+ activeRun,
136
+ linear,
137
+ syncAgentSession: (agentSessionId, issue, options) => this.syncAgentSession(linear, agentSessionId, issue, params.peekPendingSessionWakeRunType, options),
138
+ });
139
+ return;
140
+ }
141
+ if (promptBody && existingIssue && promptIntent === "status" && !directReply) {
142
+ await this.publishAgentActivity(linear, normalized.agentSession.id, this.buildStatusActivity(existingIssue, activeRun, params.peekPendingSessionWakeRunType));
143
+ await this.syncAgentSession(linear, normalized.agentSession.id, existingIssue ?? trackedIssue, params.peekPendingSessionWakeRunType, activeRun ? { activeRunType: activeRun.runType } : undefined);
144
+ return;
145
+ }
146
+ if (promptBody && promptIntent && followupIntentIsNonActionable(promptIntent) && !directReply) {
147
+ await this.publishAgentActivity(linear, normalized.agentSession.id, buildNonActionableFollowupActivity(promptIntent));
148
+ return;
149
+ }
125
150
  if (!automationEnabled && promptBody && existingIssue) {
126
151
  await this.publishAgentActivity(linear, normalized.agentSession.id, {
127
152
  type: "thought",
@@ -160,7 +185,10 @@ export class AgentSessionHandler {
160
185
  }
161
186
  if (promptBody && existingIssue && automationEnabled) {
162
187
  const hadPendingWake = this.db.issueSessions.peekIssueSessionWake(project.id, normalized.issue.id) !== undefined;
163
- const directReply = params.isDirectReplyToOutstandingQuestion(existingIssue);
188
+ if (!directReply && promptIntent && followupIntentIsNonActionable(promptIntent)) {
189
+ await this.publishAgentActivity(linear, normalized.agentSession.id, buildNonActionableFollowupActivity(promptIntent));
190
+ return;
191
+ }
164
192
  this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
165
193
  projectId: project.id,
166
194
  linearIssueId: normalized.issue.id,
@@ -184,6 +212,24 @@ export class AgentSessionHandler {
184
212
  await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(wakeRunType, "prompt"), { ephemeral: true });
185
213
  }
186
214
  }
215
+ buildStatusActivity(issue, activeRun, peekPendingSessionWakeRunType) {
216
+ const latestRun = activeRun ?? this.db.runs.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
217
+ const latestEvent = this.db.issueSessions.listIssueSessionEvents(issue.projectId, issue.linearIssueId).at(-1);
218
+ const statusNote = deriveIssueStatusNote({
219
+ issue,
220
+ latestRun,
221
+ latestEvent,
222
+ sessionSummary: extractLatestAssistantSummary(latestRun),
223
+ waitingReason: undefined,
224
+ });
225
+ const pendingRunType = peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId);
226
+ return buildFollowupStatusActivity({
227
+ issue,
228
+ ...(statusNote ? { statusNote } : {}),
229
+ ...(activeRun?.runType ? { activeRunType: activeRun.runType } : {}),
230
+ ...(pendingRunType ? { pendingRunType } : {}),
231
+ });
232
+ }
187
233
  async handleStopSignal(params) {
188
234
  const issueId = params.normalized.issue.id;
189
235
  const sessionId = params.normalized.agentSession.id;
@@ -31,6 +31,8 @@ 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 status:")
35
+ || body.startsWith("PatchRelay did not start implementation ")
34
36
  || body.startsWith("PatchRelay has stopped work as requested.")
35
37
  || body.startsWith("Merge preparation failed ")
36
38
  || body === "This thread is for an agent session with patchrelay.";
@@ -1,3 +1,4 @@
1
+ import { classifyFollowupIntent, followupIntentIsNonActionable, followupIntentQueuesWork } from "../followup-intent.js";
1
2
  import { triggerEventAllowed } from "../project-resolution.js";
2
3
  import { hasExplicitPatchRelayWakeIntent, isInertPatchRelayComment, isPatchRelayManagedCommentAuthor, } from "./comment-policy.js";
3
4
  import { classifyIssue } from "../issue-class.js";
@@ -57,9 +58,10 @@ export class CommentWakeHandler {
57
58
  });
58
59
  return;
59
60
  }
61
+ const directReply = params.isDirectReplyToOutstandingQuestion(issue);
62
+ const intent = classifyFollowupIntent(trimmedBody);
60
63
  if (!issue.activeRunId) {
61
64
  if (ENQUEUEABLE_STATES.has(issue.factoryState)) {
62
- const directReply = params.isDirectReplyToOutstandingQuestion(issue);
63
65
  const wakeIntent = issueClass === "orchestration" || directReply || hasExplicitPatchRelayWakeIntent(trimmedBody);
64
66
  if (!wakeIntent) {
65
67
  this.feed?.publish({
@@ -73,6 +75,42 @@ export class CommentWakeHandler {
73
75
  });
74
76
  return;
75
77
  }
78
+ if (intent === "stop") {
79
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
80
+ projectId: project.id,
81
+ linearIssueId: normalized.issue.id,
82
+ eventType: "stop_requested",
83
+ eventJson: JSON.stringify({
84
+ body: trimmedBody,
85
+ author: normalized.comment.userName,
86
+ }),
87
+ });
88
+ this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(project.id, normalized.issue.id);
89
+ this.feed?.publish({
90
+ level: "info",
91
+ kind: "comment",
92
+ projectId: project.id,
93
+ issueKey: trackedIssue?.issueKey,
94
+ status: "stopped",
95
+ summary: "Stop request recorded from Linear comment",
96
+ detail: trimmedBody.slice(0, 200),
97
+ });
98
+ return;
99
+ }
100
+ if (!directReply && !followupIntentQueuesWork(intent)) {
101
+ this.feed?.publish({
102
+ level: "info",
103
+ kind: "comment",
104
+ projectId: project.id,
105
+ issueKey: trackedIssue?.issueKey,
106
+ status: intent === "status" ? "status_requested" : "ignored",
107
+ summary: intent === "status"
108
+ ? "Ignored status comment without queueing work"
109
+ : "Ignored non-actionable follow-up comment",
110
+ detail: trimmedBody.slice(0, 200),
111
+ });
112
+ return;
113
+ }
76
114
  const runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
77
115
  const hadPendingWake = this.db.issueSessions.peekIssueSessionWake(project.id, normalized.issue.id) !== undefined;
78
116
  this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
@@ -102,6 +140,60 @@ export class CommentWakeHandler {
102
140
  const run = this.db.runs.getRunById(issue.activeRunId);
103
141
  if (!run?.threadId || !run.turnId)
104
142
  return;
143
+ if (intent === "stop") {
144
+ try {
145
+ await this.codex.steerTurn({
146
+ threadId: run.threadId,
147
+ turnId: run.turnId,
148
+ input: "STOP: The user has requested you stop working immediately. Do not make further changes. Wrap up and exit.",
149
+ });
150
+ }
151
+ catch (error) {
152
+ this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for comment stop request");
153
+ }
154
+ this.db.runs.finishRun(run.id, { status: "released", threadId: run.threadId, turnId: run.turnId });
155
+ this.db.issueSessions.upsertIssueRespectingActiveLease(project.id, normalized.issue.id, {
156
+ projectId: project.id,
157
+ linearIssueId: normalized.issue.id,
158
+ activeRunId: null,
159
+ factoryState: "awaiting_input",
160
+ });
161
+ this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
162
+ projectId: project.id,
163
+ linearIssueId: normalized.issue.id,
164
+ eventType: "stop_requested",
165
+ eventJson: JSON.stringify({
166
+ body: trimmedBody,
167
+ author: normalized.comment.userName,
168
+ }),
169
+ });
170
+ this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(project.id, normalized.issue.id);
171
+ this.feed?.publish({
172
+ level: "info",
173
+ kind: "comment",
174
+ projectId: project.id,
175
+ issueKey: trackedIssue?.issueKey,
176
+ stage: run.runType,
177
+ status: "stopped",
178
+ summary: "Stop request delivered to active workflow",
179
+ });
180
+ return;
181
+ }
182
+ if (!directReply && followupIntentIsNonActionable(intent)) {
183
+ this.feed?.publish({
184
+ level: "info",
185
+ kind: "comment",
186
+ projectId: project.id,
187
+ issueKey: trackedIssue?.issueKey,
188
+ stage: run.runType,
189
+ status: intent === "status" ? "status_requested" : "ignored",
190
+ summary: intent === "status"
191
+ ? "Ignored status comment without steering active workflow"
192
+ : "Ignored non-actionable follow-up comment",
193
+ detail: trimmedBody.slice(0, 200),
194
+ });
195
+ return;
196
+ }
105
197
  const body = [
106
198
  "New Linear comment received while you are working.",
107
199
  normalized.comment.userName ? `Author: ${normalized.comment.userName}` : undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {