patchrelay 0.36.7 → 0.36.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.
- package/dist/build-info.json +3 -3
- package/dist/cli/cluster-health.js +1 -1
- package/dist/cli/data.js +11 -11
- package/dist/db/issue-session-store.js +292 -0
- package/dist/db/run-store.js +127 -0
- package/dist/db/webhook-event-store.js +71 -0
- package/dist/db.js +22 -520
- package/dist/github-webhook-handler.js +25 -25
- package/dist/idle-reconciliation.js +5 -5
- package/dist/issue-query-service.js +9 -9
- package/dist/issue-session-lease-service.js +143 -0
- package/dist/linear-session-sync.js +4 -4
- package/dist/queue-health-monitor.js +2 -2
- package/dist/run-finalizer.js +161 -0
- package/dist/run-launcher.js +193 -0
- package/dist/run-orchestrator.js +148 -856
- package/dist/run-recovery-service.js +203 -0
- package/dist/run-wake-planner.js +101 -0
- package/dist/service.js +24 -24
- package/dist/tracked-issue-projector.js +69 -0
- package/dist/webhook-handler.js +59 -688
- package/dist/webhooks/agent-session-handler.js +212 -0
- package/dist/webhooks/comment-policy.js +41 -0
- package/dist/webhooks/comment-wake-handler.js +133 -0
- package/dist/webhooks/decision-helpers.js +74 -0
- package/dist/webhooks/desired-stage-recorder.js +177 -0
- package/dist/webhooks/issue-removal-handler.js +68 -0
- package/package.json +1 -1
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { buildAgentSessionPlanForIssue, } from "../agent-session-plan.js";
|
|
2
|
+
import { buildAgentSessionExternalUrls } from "../agent-session-presentation.js";
|
|
3
|
+
import { buildAlreadyRunningThought, buildDelegationThought, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "../linear-session-reporting.js";
|
|
4
|
+
import { triggerEventAllowed } from "../project-resolution.js";
|
|
5
|
+
export class AgentSessionHandler {
|
|
6
|
+
config;
|
|
7
|
+
db;
|
|
8
|
+
linearProvider;
|
|
9
|
+
codex;
|
|
10
|
+
logger;
|
|
11
|
+
feed;
|
|
12
|
+
constructor(config, db, linearProvider, codex, logger, feed) {
|
|
13
|
+
this.config = config;
|
|
14
|
+
this.db = db;
|
|
15
|
+
this.linearProvider = linearProvider;
|
|
16
|
+
this.codex = codex;
|
|
17
|
+
this.logger = logger;
|
|
18
|
+
this.feed = feed;
|
|
19
|
+
}
|
|
20
|
+
async handle(params) {
|
|
21
|
+
const { normalized, project, trackedIssue, wakeRunType, delegated } = params;
|
|
22
|
+
if (!normalized.agentSession?.id || !normalized.issue)
|
|
23
|
+
return;
|
|
24
|
+
const linear = await this.linearProvider.forProject(project.id);
|
|
25
|
+
if (!linear)
|
|
26
|
+
return;
|
|
27
|
+
const existingIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
28
|
+
const activeRun = existingIssue?.activeRunId ? this.db.runs.getRunById(existingIssue.activeRunId) : undefined;
|
|
29
|
+
if (normalized.triggerEvent === "agentSessionCreated") {
|
|
30
|
+
if (!delegated) {
|
|
31
|
+
const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
32
|
+
if (latestIssue ?? trackedIssue) {
|
|
33
|
+
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, params.peekPendingSessionWakeRunType);
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (wakeRunType) {
|
|
38
|
+
const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
39
|
+
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, params.peekPendingSessionWakeRunType, { pendingRunType: wakeRunType });
|
|
40
|
+
await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(wakeRunType));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (activeRun) {
|
|
44
|
+
const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
45
|
+
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, params.peekPendingSessionWakeRunType, { activeRunType: activeRun.runType });
|
|
46
|
+
await this.publishAgentActivity(linear, normalized.agentSession.id, buildAlreadyRunningThought(activeRun.runType));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const blockerSummary = trackedIssue?.blockedByCount
|
|
50
|
+
? `PatchRelay is delegated and waiting on blockers to reach Done: ${trackedIssue.blockedByKeys.join(", ")}.`
|
|
51
|
+
: "PatchRelay is delegated, but no work is queued. Delegate the issue or move it to Start to trigger implementation.";
|
|
52
|
+
await this.publishAgentActivity(linear, normalized.agentSession.id, {
|
|
53
|
+
type: "elicitation",
|
|
54
|
+
body: blockerSummary,
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (normalized.triggerEvent === "agentSignal" && normalized.agentSession.signal === "stop") {
|
|
59
|
+
await this.handleStopSignal({
|
|
60
|
+
normalized,
|
|
61
|
+
project,
|
|
62
|
+
trackedIssue,
|
|
63
|
+
activeRun,
|
|
64
|
+
linear,
|
|
65
|
+
syncAgentSession: (agentSessionId, issue, options) => this.syncAgentSession(linear, agentSessionId, issue, params.peekPendingSessionWakeRunType, options),
|
|
66
|
+
});
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (normalized.triggerEvent !== "agentPrompted")
|
|
70
|
+
return;
|
|
71
|
+
if (!triggerEventAllowed(project, normalized.triggerEvent))
|
|
72
|
+
return;
|
|
73
|
+
const promptBody = normalized.agentSession.promptBody?.trim();
|
|
74
|
+
if (activeRun && promptBody && activeRun.threadId && activeRun.turnId) {
|
|
75
|
+
const input = `New Linear agent prompt received while you are working.\n\n${promptBody}`;
|
|
76
|
+
try {
|
|
77
|
+
await this.codex.steerTurn({ threadId: activeRun.threadId, turnId: activeRun.turnId, input });
|
|
78
|
+
this.feed?.publish({
|
|
79
|
+
level: "info",
|
|
80
|
+
kind: "agent",
|
|
81
|
+
projectId: project.id,
|
|
82
|
+
issueKey: trackedIssue?.issueKey,
|
|
83
|
+
stage: activeRun.runType,
|
|
84
|
+
status: "delivered",
|
|
85
|
+
summary: `Delivered follow-up prompt to active ${activeRun.runType} workflow`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to deliver follow-up prompt");
|
|
90
|
+
this.feed?.publish({
|
|
91
|
+
level: "warn",
|
|
92
|
+
kind: "agent",
|
|
93
|
+
projectId: project.id,
|
|
94
|
+
issueKey: trackedIssue?.issueKey,
|
|
95
|
+
stage: activeRun.runType,
|
|
96
|
+
status: "delivery_failed",
|
|
97
|
+
summary: `Could not deliver follow-up prompt to active ${activeRun.runType} workflow`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
await this.publishAgentActivity(linear, normalized.agentSession.id, buildPromptDeliveredThought(activeRun.runType), { ephemeral: true });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (promptBody && existingIssue && (delegated || existingIssue.factoryState === "awaiting_input")) {
|
|
104
|
+
const hadPendingWake = this.db.issueSessions.peekIssueSessionWake(project.id, normalized.issue.id) !== undefined;
|
|
105
|
+
const directReply = params.isDirectReplyToOutstandingQuestion(existingIssue);
|
|
106
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
|
|
107
|
+
projectId: project.id,
|
|
108
|
+
linearIssueId: normalized.issue.id,
|
|
109
|
+
eventType: directReply ? "direct_reply" : "followup_prompt",
|
|
110
|
+
eventJson: JSON.stringify({
|
|
111
|
+
text: promptBody,
|
|
112
|
+
source: "linear_agent_prompt",
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
const queuedRunType = hadPendingWake
|
|
116
|
+
? params.peekPendingSessionWakeRunType(project.id, normalized.issue.id)
|
|
117
|
+
: params.enqueuePendingSessionWake(project.id, normalized.issue.id);
|
|
118
|
+
const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
119
|
+
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, params.peekPendingSessionWakeRunType, { pendingRunType: queuedRunType ?? wakeRunType ?? (existingIssue.prReviewState === "changes_requested" ? "review_fix" : "implementation") });
|
|
120
|
+
await this.publishAgentActivity(linear, normalized.agentSession.id, buildPromptDeliveredThought(queuedRunType ?? wakeRunType ?? "implementation"), { ephemeral: true });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (wakeRunType) {
|
|
124
|
+
const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
125
|
+
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, params.peekPendingSessionWakeRunType, { pendingRunType: wakeRunType });
|
|
126
|
+
await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(wakeRunType, "prompt"), { ephemeral: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async handleStopSignal(params) {
|
|
130
|
+
const issueId = params.normalized.issue.id;
|
|
131
|
+
const sessionId = params.normalized.agentSession.id;
|
|
132
|
+
if (params.activeRun?.threadId && params.activeRun.turnId) {
|
|
133
|
+
try {
|
|
134
|
+
await this.codex.steerTurn({
|
|
135
|
+
threadId: params.activeRun.threadId,
|
|
136
|
+
turnId: params.activeRun.turnId,
|
|
137
|
+
input: "STOP: The user has requested you stop working immediately. Do not make further changes. Wrap up and exit.",
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
this.logger.warn({ issueKey: params.trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to steer Codex turn for stop signal");
|
|
142
|
+
}
|
|
143
|
+
this.db.runs.finishRun(params.activeRun.id, { status: "released", threadId: params.activeRun.threadId, turnId: params.activeRun.turnId });
|
|
144
|
+
}
|
|
145
|
+
this.db.issueSessions.upsertIssueRespectingActiveLease(params.project.id, issueId, {
|
|
146
|
+
projectId: params.project.id,
|
|
147
|
+
linearIssueId: issueId,
|
|
148
|
+
activeRunId: null,
|
|
149
|
+
factoryState: "awaiting_input",
|
|
150
|
+
agentSessionId: sessionId,
|
|
151
|
+
});
|
|
152
|
+
this.db.issueSessions.appendIssueSessionEvent({
|
|
153
|
+
projectId: params.project.id,
|
|
154
|
+
linearIssueId: issueId,
|
|
155
|
+
eventType: "stop_requested",
|
|
156
|
+
dedupeKey: `stop_requested:${issueId}`,
|
|
157
|
+
});
|
|
158
|
+
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(params.project.id, issueId);
|
|
159
|
+
this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(params.project.id, issueId);
|
|
160
|
+
this.feed?.publish({
|
|
161
|
+
level: "info",
|
|
162
|
+
kind: "agent",
|
|
163
|
+
projectId: params.project.id,
|
|
164
|
+
issueKey: params.trackedIssue?.issueKey,
|
|
165
|
+
status: "stopped",
|
|
166
|
+
summary: "Stop signal received - work halted",
|
|
167
|
+
});
|
|
168
|
+
const updatedIssue = this.db.getIssue(params.project.id, issueId);
|
|
169
|
+
await this.publishAgentActivity(params.linear, sessionId, buildStopConfirmationActivity());
|
|
170
|
+
await params.syncAgentSession(sessionId, updatedIssue ?? params.trackedIssue);
|
|
171
|
+
}
|
|
172
|
+
async publishAgentActivity(linear, agentSessionId, content, options) {
|
|
173
|
+
try {
|
|
174
|
+
await linear.createAgentActivity({
|
|
175
|
+
agentSessionId,
|
|
176
|
+
content,
|
|
177
|
+
ephemeral: options?.ephemeral ?? content.type === "thought",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch (error) {
|
|
181
|
+
this.logger.warn({ agentSessionId, error: error instanceof Error ? error.message : String(error) }, "Failed to publish Linear agent activity");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async syncAgentSession(linear, agentSessionId, issue, peekPendingSessionWakeRunType, options) {
|
|
185
|
+
if (!linear.updateAgentSession)
|
|
186
|
+
return;
|
|
187
|
+
try {
|
|
188
|
+
const prUrl = issue && "prUrl" in issue ? issue.prUrl : undefined;
|
|
189
|
+
const externalUrls = buildAgentSessionExternalUrls(this.config, {
|
|
190
|
+
...(issue?.issueKey ? { issueKey: issue.issueKey } : {}),
|
|
191
|
+
...(prUrl ? { prUrl } : {}),
|
|
192
|
+
});
|
|
193
|
+
await linear.updateAgentSession({
|
|
194
|
+
agentSessionId,
|
|
195
|
+
...(externalUrls ? { externalUrls } : {}),
|
|
196
|
+
...(issue
|
|
197
|
+
? {
|
|
198
|
+
plan: buildAgentSessionPlanForIssue({
|
|
199
|
+
factoryState: issue.factoryState,
|
|
200
|
+
pendingRunType: options?.pendingRunType ?? peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId),
|
|
201
|
+
ciRepairAttempts: "ciRepairAttempts" in issue ? issue.ciRepairAttempts : 0,
|
|
202
|
+
queueRepairAttempts: "queueRepairAttempts" in issue ? issue.queueRepairAttempts : 0,
|
|
203
|
+
}, options?.activeRunType ? { activeRunType: options.activeRunType } : undefined),
|
|
204
|
+
}
|
|
205
|
+
: {}),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
this.logger.warn({ agentSessionId, error: error instanceof Error ? error.message : String(error) }, "Failed to update Linear agent session");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function isInertPatchRelayComment(issue, commentId, body, actorType) {
|
|
2
|
+
if (commentId === issue.statusCommentId) {
|
|
3
|
+
return true;
|
|
4
|
+
}
|
|
5
|
+
if (body.startsWith("## PatchRelay status")
|
|
6
|
+
&& body.includes("_PatchRelay updates this comment as it works. Review and merge remain downstream._")) {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
const normalizedActorType = actorType?.trim().toLowerCase();
|
|
10
|
+
if (normalizedActorType && normalizedActorType !== "user") {
|
|
11
|
+
return isPatchRelayGeneratedActivityComment(body);
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
export function isPatchRelayManagedCommentAuthor(installation, actor, commentUserName) {
|
|
16
|
+
const actorName = actor?.name?.trim().toLowerCase();
|
|
17
|
+
const commentAuthor = commentUserName?.trim().toLowerCase();
|
|
18
|
+
const installationName = installation?.actorName?.trim().toLowerCase();
|
|
19
|
+
if (installation?.actorId && actor?.id === installation.actorId) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
if (installationName && actorName === installationName) {
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
if (actorName === "patchrelay" || commentAuthor === "patchrelay") {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
export function isPatchRelayGeneratedActivityComment(body) {
|
|
31
|
+
return body.startsWith("PatchRelay needs human help to continue.")
|
|
32
|
+
|| body.startsWith("PatchRelay is already working on ")
|
|
33
|
+
|| body.startsWith("PatchRelay received the ")
|
|
34
|
+
|| body.startsWith("PatchRelay routed your latest instructions into ")
|
|
35
|
+
|| body.startsWith("PatchRelay has stopped work as requested.")
|
|
36
|
+
|| body.startsWith("Merge preparation failed ")
|
|
37
|
+
|| body === "This thread is for an agent session with patchrelay.";
|
|
38
|
+
}
|
|
39
|
+
export function hasExplicitPatchRelayWakeIntent(body) {
|
|
40
|
+
return /\bpatchrelay\b/i.test(body);
|
|
41
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { triggerEventAllowed } from "../project-resolution.js";
|
|
2
|
+
import { hasExplicitPatchRelayWakeIntent, isInertPatchRelayComment, isPatchRelayManagedCommentAuthor, } from "./comment-policy.js";
|
|
3
|
+
const ENQUEUEABLE_STATES = new Set(["pr_open", "changes_requested", "implementing", "delegated", "awaiting_input"]);
|
|
4
|
+
export class CommentWakeHandler {
|
|
5
|
+
db;
|
|
6
|
+
codex;
|
|
7
|
+
logger;
|
|
8
|
+
feed;
|
|
9
|
+
constructor(db, codex, logger, feed) {
|
|
10
|
+
this.db = db;
|
|
11
|
+
this.codex = codex;
|
|
12
|
+
this.logger = logger;
|
|
13
|
+
this.feed = feed;
|
|
14
|
+
}
|
|
15
|
+
async handle(params) {
|
|
16
|
+
const { normalized, project, trackedIssue } = params;
|
|
17
|
+
if ((normalized.triggerEvent !== "commentCreated" && normalized.triggerEvent !== "commentUpdated")
|
|
18
|
+
|| !normalized.comment?.body
|
|
19
|
+
|| !normalized.issue) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (!triggerEventAllowed(project, normalized.triggerEvent))
|
|
23
|
+
return;
|
|
24
|
+
const issue = this.db.getIssue(project.id, normalized.issue.id);
|
|
25
|
+
if (!issue)
|
|
26
|
+
return;
|
|
27
|
+
const trimmedBody = normalized.comment.body.trim();
|
|
28
|
+
const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
|
|
29
|
+
const selfAuthored = isPatchRelayManagedCommentAuthor(installation, normalized.actor, normalized.comment.userName);
|
|
30
|
+
const inertPatchRelayComment = isInertPatchRelayComment(issue, normalized.comment.id, trimmedBody, normalized.actor?.type);
|
|
31
|
+
if (selfAuthored || inertPatchRelayComment) {
|
|
32
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
|
|
33
|
+
projectId: project.id,
|
|
34
|
+
linearIssueId: normalized.issue.id,
|
|
35
|
+
eventType: "self_comment",
|
|
36
|
+
eventJson: JSON.stringify({
|
|
37
|
+
body: trimmedBody,
|
|
38
|
+
author: normalized.comment.userName,
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!issue.activeRunId) {
|
|
44
|
+
if (ENQUEUEABLE_STATES.has(issue.factoryState)) {
|
|
45
|
+
const directReply = params.isDirectReplyToOutstandingQuestion(issue);
|
|
46
|
+
const wakeIntent = directReply || hasExplicitPatchRelayWakeIntent(trimmedBody);
|
|
47
|
+
if (!wakeIntent) {
|
|
48
|
+
this.feed?.publish({
|
|
49
|
+
level: "info",
|
|
50
|
+
kind: "comment",
|
|
51
|
+
projectId: project.id,
|
|
52
|
+
issueKey: trackedIssue?.issueKey,
|
|
53
|
+
status: "ignored",
|
|
54
|
+
summary: "Ignored comment with no explicit PatchRelay wake intent",
|
|
55
|
+
detail: trimmedBody.slice(0, 200),
|
|
56
|
+
});
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
|
|
60
|
+
const hadPendingWake = this.db.issueSessions.peekIssueSessionWake(project.id, normalized.issue.id) !== undefined;
|
|
61
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
|
|
62
|
+
projectId: project.id,
|
|
63
|
+
linearIssueId: normalized.issue.id,
|
|
64
|
+
eventType: directReply ? "direct_reply" : "followup_comment",
|
|
65
|
+
eventJson: JSON.stringify({
|
|
66
|
+
body: trimmedBody,
|
|
67
|
+
author: normalized.comment.userName,
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
const queuedRunType = hadPendingWake
|
|
71
|
+
? params.peekPendingSessionWakeRunType(project.id, normalized.issue.id)
|
|
72
|
+
: params.enqueuePendingSessionWake(project.id, normalized.issue.id);
|
|
73
|
+
this.feed?.publish({
|
|
74
|
+
level: "info",
|
|
75
|
+
kind: "comment",
|
|
76
|
+
projectId: project.id,
|
|
77
|
+
issueKey: trackedIssue?.issueKey,
|
|
78
|
+
status: "enqueued",
|
|
79
|
+
summary: `Comment enqueued ${(queuedRunType ?? runType)} run`,
|
|
80
|
+
detail: trimmedBody.slice(0, 200),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const run = this.db.runs.getRunById(issue.activeRunId);
|
|
86
|
+
if (!run?.threadId || !run.turnId)
|
|
87
|
+
return;
|
|
88
|
+
const body = [
|
|
89
|
+
"New Linear comment received while you are working.",
|
|
90
|
+
normalized.comment.userName ? `Author: ${normalized.comment.userName}` : undefined,
|
|
91
|
+
"",
|
|
92
|
+
trimmedBody,
|
|
93
|
+
].filter(Boolean).join("\n");
|
|
94
|
+
try {
|
|
95
|
+
await this.codex.steerTurn({ threadId: run.threadId, turnId: run.turnId, input: body });
|
|
96
|
+
this.feed?.publish({
|
|
97
|
+
level: "info",
|
|
98
|
+
kind: "comment",
|
|
99
|
+
projectId: project.id,
|
|
100
|
+
issueKey: trackedIssue?.issueKey,
|
|
101
|
+
stage: run.runType,
|
|
102
|
+
status: "delivered",
|
|
103
|
+
summary: `Delivered follow-up comment to active ${run.runType} workflow`,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to deliver follow-up comment");
|
|
108
|
+
const hadPendingWake = this.db.issueSessions.hasPendingIssueSessionEvents(project.id, normalized.issue.id);
|
|
109
|
+
const directReply = params.isDirectReplyToOutstandingQuestion(issue);
|
|
110
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
|
|
111
|
+
projectId: project.id,
|
|
112
|
+
linearIssueId: normalized.issue.id,
|
|
113
|
+
eventType: directReply ? "direct_reply" : "followup_comment",
|
|
114
|
+
eventJson: JSON.stringify({
|
|
115
|
+
body: trimmedBody,
|
|
116
|
+
author: normalized.comment.userName,
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
if (!hadPendingWake) {
|
|
120
|
+
params.enqueuePendingSessionWake(project.id, normalized.issue.id);
|
|
121
|
+
}
|
|
122
|
+
this.feed?.publish({
|
|
123
|
+
level: "warn",
|
|
124
|
+
kind: "comment",
|
|
125
|
+
projectId: project.id,
|
|
126
|
+
issueKey: trackedIssue?.issueKey,
|
|
127
|
+
stage: run.runType,
|
|
128
|
+
status: "delivery_failed",
|
|
129
|
+
summary: `Could not deliver follow-up comment to active ${run.runType} workflow`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { TERMINAL_STATES } from "../factory-state.js";
|
|
2
|
+
export function decideRunIntent(p) {
|
|
3
|
+
const wakeEligibleState = p.currentState === undefined
|
|
4
|
+
|| p.currentState === "delegated"
|
|
5
|
+
|| p.currentState === "awaiting_input";
|
|
6
|
+
const delegatedStartupRecovery = p.delegated
|
|
7
|
+
&& p.currentState === "awaiting_input"
|
|
8
|
+
&& p.triggerEvent === "issueCreated";
|
|
9
|
+
if (p.delegated && (p.triggerAllowed || delegatedStartupRecovery) && p.unresolvedBlockers === 0
|
|
10
|
+
&& !p.hasActiveRun && !p.hasPendingWake && !p.terminal && wakeEligibleState) {
|
|
11
|
+
return "implementation";
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
export function decideActiveRunRelease(p) {
|
|
16
|
+
if (!p.hasActiveRun)
|
|
17
|
+
return { release: false };
|
|
18
|
+
if (p.terminal)
|
|
19
|
+
return { release: true, reason: "Issue reached terminal state during active run" };
|
|
20
|
+
if (p.triggerEvent === "delegateChanged" && !p.delegated)
|
|
21
|
+
return { release: true, reason: "Un-delegated from PatchRelay" };
|
|
22
|
+
return { release: false };
|
|
23
|
+
}
|
|
24
|
+
export function decideUnDelegation(p) {
|
|
25
|
+
if (p.triggerEvent !== "delegateChanged" || p.delegated)
|
|
26
|
+
return { clearPending: false };
|
|
27
|
+
if (!p.currentState)
|
|
28
|
+
return { clearPending: false };
|
|
29
|
+
const pastNoReturn = p.currentState === "awaiting_queue" || TERMINAL_STATES.has(p.currentState);
|
|
30
|
+
if (pastNoReturn)
|
|
31
|
+
return { clearPending: false };
|
|
32
|
+
return { factoryState: "awaiting_input", clearPending: true };
|
|
33
|
+
}
|
|
34
|
+
export function decideAgentSession(p) {
|
|
35
|
+
if (p.sessionId)
|
|
36
|
+
return p.sessionId;
|
|
37
|
+
if (p.triggerEvent === "delegateChanged" && !p.delegated)
|
|
38
|
+
return null;
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
export function isResolvedLinearState(stateType, stateName) {
|
|
42
|
+
return stateType === "completed" || stateName?.trim().toLowerCase() === "done";
|
|
43
|
+
}
|
|
44
|
+
export function isTerminalDelegationState(existingIssue, hydratedIssue) {
|
|
45
|
+
if (existingIssue?.prState === "merged") {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (existingIssue?.factoryState && existingIssue.factoryState !== "awaiting_input" && TERMINAL_STATES.has(existingIssue.factoryState)) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return isResolvedLinearState(hydratedIssue.stateType, hydratedIssue.stateName);
|
|
52
|
+
}
|
|
53
|
+
export function hasCompleteIssueContext(issue) {
|
|
54
|
+
return Boolean(issue.stateName && issue.delegateId && issue.teamId && issue.teamKey);
|
|
55
|
+
}
|
|
56
|
+
export function mergeIssueMetadata(issue, liveIssue) {
|
|
57
|
+
return {
|
|
58
|
+
...issue,
|
|
59
|
+
...(issue.identifier ? {} : liveIssue.identifier ? { identifier: liveIssue.identifier } : {}),
|
|
60
|
+
...(issue.title ? {} : liveIssue.title ? { title: liveIssue.title } : {}),
|
|
61
|
+
...(issue.url ? {} : liveIssue.url ? { url: liveIssue.url } : {}),
|
|
62
|
+
...(issue.teamId ? {} : liveIssue.teamId ? { teamId: liveIssue.teamId } : {}),
|
|
63
|
+
...(issue.teamKey ? {} : liveIssue.teamKey ? { teamKey: liveIssue.teamKey } : {}),
|
|
64
|
+
...(issue.stateId ? {} : liveIssue.stateId ? { stateId: liveIssue.stateId } : {}),
|
|
65
|
+
...(issue.stateName ? {} : liveIssue.stateName ? { stateName: liveIssue.stateName } : {}),
|
|
66
|
+
...(issue.stateType ? {} : liveIssue.stateType ? { stateType: liveIssue.stateType } : {}),
|
|
67
|
+
...(issue.delegateId ? {} : liveIssue.delegateId ? { delegateId: liveIssue.delegateId } : {}),
|
|
68
|
+
...(issue.delegateName ? {} : liveIssue.delegateName ? { delegateName: liveIssue.delegateName } : {}),
|
|
69
|
+
relationsKnown: issue.relationsKnown || liveIssue.blockedBy !== undefined || liveIssue.blocks !== undefined,
|
|
70
|
+
labelNames: issue.labelNames.length > 0 ? issue.labelNames : (liveIssue.labels ?? []).map((l) => l.name),
|
|
71
|
+
blockedBy: issue.relationsKnown ? issue.blockedBy : (liveIssue.blockedBy ?? issue.blockedBy),
|
|
72
|
+
blocks: issue.relationsKnown ? issue.blocks : (liveIssue.blocks ?? issue.blocks),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { triggerEventAllowed } from "../project-resolution.js";
|
|
2
|
+
import { decideActiveRunRelease, decideAgentSession, decideRunIntent, decideUnDelegation, isTerminalDelegationState, mergeIssueMetadata, } from "./decision-helpers.js";
|
|
3
|
+
export class DesiredStageRecorder {
|
|
4
|
+
db;
|
|
5
|
+
linearProvider;
|
|
6
|
+
feed;
|
|
7
|
+
constructor(db, linearProvider, feed) {
|
|
8
|
+
this.db = db;
|
|
9
|
+
this.linearProvider = linearProvider;
|
|
10
|
+
this.feed = feed;
|
|
11
|
+
}
|
|
12
|
+
async record(params) {
|
|
13
|
+
const normalizedIssue = params.normalized.issue;
|
|
14
|
+
if (!normalizedIssue) {
|
|
15
|
+
return { issue: undefined, wakeRunType: undefined, delegated: false };
|
|
16
|
+
}
|
|
17
|
+
const existingIssue = this.db.getIssue(params.project.id, normalizedIssue.id);
|
|
18
|
+
const activeRun = existingIssue?.activeRunId ? this.db.runs.getRunById(existingIssue.activeRunId) : undefined;
|
|
19
|
+
const delegated = this.isDelegatedToPatchRelay(params.project, params.normalized);
|
|
20
|
+
const triggerAllowed = triggerEventAllowed(params.project, params.normalized.triggerEvent);
|
|
21
|
+
const incomingAgentSessionId = params.normalized.agentSession?.id;
|
|
22
|
+
const hasPendingWake = this.db.issueSessions.peekIssueSessionWake(params.project.id, normalizedIssue.id) !== undefined;
|
|
23
|
+
if (!existingIssue && !delegated && !incomingAgentSessionId) {
|
|
24
|
+
return { issue: undefined, wakeRunType: undefined, delegated };
|
|
25
|
+
}
|
|
26
|
+
const hydratedIssue = await this.syncIssueDependencies(params.project.id, normalizedIssue);
|
|
27
|
+
const unresolvedBlockers = this.db.countUnresolvedBlockers(params.project.id, normalizedIssue.id);
|
|
28
|
+
const terminal = isTerminalDelegationState(existingIssue, hydratedIssue);
|
|
29
|
+
const desiredStage = decideRunIntent({
|
|
30
|
+
delegated,
|
|
31
|
+
triggerAllowed,
|
|
32
|
+
triggerEvent: params.normalized.triggerEvent,
|
|
33
|
+
unresolvedBlockers,
|
|
34
|
+
hasActiveRun: Boolean(activeRun),
|
|
35
|
+
hasPendingWake,
|
|
36
|
+
terminal,
|
|
37
|
+
currentState: existingIssue?.factoryState,
|
|
38
|
+
});
|
|
39
|
+
const runRelease = decideActiveRunRelease({
|
|
40
|
+
hasActiveRun: Boolean(activeRun),
|
|
41
|
+
terminal,
|
|
42
|
+
triggerEvent: params.normalized.triggerEvent,
|
|
43
|
+
delegated,
|
|
44
|
+
});
|
|
45
|
+
const undelegation = decideUnDelegation({
|
|
46
|
+
triggerEvent: params.normalized.triggerEvent,
|
|
47
|
+
delegated,
|
|
48
|
+
currentState: existingIssue?.factoryState,
|
|
49
|
+
});
|
|
50
|
+
const delegatedStateRecovery = delegated
|
|
51
|
+
&& !terminal
|
|
52
|
+
&& existingIssue?.factoryState === "awaiting_input"
|
|
53
|
+
&& !undelegation.factoryState;
|
|
54
|
+
const existingWakeRunType = existingIssue
|
|
55
|
+
? params.peekPendingSessionWakeRunType(params.project.id, normalizedIssue.id)
|
|
56
|
+
: undefined;
|
|
57
|
+
const clearPending = (unresolvedBlockers > 0 && existingWakeRunType === "implementation" && !activeRun)
|
|
58
|
+
|| undelegation.clearPending;
|
|
59
|
+
const agentSessionId = decideAgentSession({
|
|
60
|
+
sessionId: params.normalized.agentSession?.id,
|
|
61
|
+
triggerEvent: params.normalized.triggerEvent,
|
|
62
|
+
delegated,
|
|
63
|
+
});
|
|
64
|
+
const commitIssueUpdate = () => {
|
|
65
|
+
const record = this.db.upsertIssue({
|
|
66
|
+
projectId: params.project.id,
|
|
67
|
+
linearIssueId: normalizedIssue.id,
|
|
68
|
+
...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
|
|
69
|
+
...(hydratedIssue.title ? { title: hydratedIssue.title } : {}),
|
|
70
|
+
...(hydratedIssue.description ? { description: hydratedIssue.description } : {}),
|
|
71
|
+
...(hydratedIssue.url ? { url: hydratedIssue.url } : {}),
|
|
72
|
+
...(hydratedIssue.priority != null ? { priority: hydratedIssue.priority } : {}),
|
|
73
|
+
...(hydratedIssue.estimate != null ? { estimate: hydratedIssue.estimate } : {}),
|
|
74
|
+
...(hydratedIssue.stateName ? { currentLinearState: hydratedIssue.stateName } : {}),
|
|
75
|
+
...(hydratedIssue.stateType ? { currentLinearStateType: hydratedIssue.stateType } : {}),
|
|
76
|
+
...(!existingIssue && !delegated && incomingAgentSessionId ? { factoryState: "awaiting_input" } : {}),
|
|
77
|
+
...(delegatedStateRecovery ? { factoryState: "delegated" } : {}),
|
|
78
|
+
...(desiredStage ? { pendingRunType: null, pendingRunContextJson: null, factoryState: "delegated" } : {}),
|
|
79
|
+
...(clearPending ? { pendingRunType: null, pendingRunContextJson: null } : {}),
|
|
80
|
+
...(agentSessionId !== undefined ? { agentSessionId } : {}),
|
|
81
|
+
...(runRelease.release ? { activeRunId: null } : {}),
|
|
82
|
+
...(undelegation.factoryState ? { factoryState: undelegation.factoryState } : {}),
|
|
83
|
+
});
|
|
84
|
+
if (runRelease.release && activeRun && runRelease.reason) {
|
|
85
|
+
this.db.runs.finishRun(activeRun.id, { status: "released", failureReason: runRelease.reason });
|
|
86
|
+
}
|
|
87
|
+
return record;
|
|
88
|
+
};
|
|
89
|
+
const activeLease = this.db.issueSessions.getActiveIssueSessionLease(params.project.id, normalizedIssue.id);
|
|
90
|
+
const issue = activeLease
|
|
91
|
+
? this.db.issueSessions.withIssueSessionLease(params.project.id, normalizedIssue.id, activeLease.leaseId, commitIssueUpdate) ?? (existingIssue ?? this.db.upsertIssue({
|
|
92
|
+
projectId: params.project.id,
|
|
93
|
+
linearIssueId: normalizedIssue.id,
|
|
94
|
+
...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
|
|
95
|
+
}))
|
|
96
|
+
: this.db.transaction(commitIssueUpdate);
|
|
97
|
+
if (undelegation.factoryState) {
|
|
98
|
+
if (activeRun?.threadId && activeRun.turnId) {
|
|
99
|
+
await params.stopActiveRun(activeRun, "STOP: The issue was un-delegated from PatchRelay. Stop working immediately and exit.");
|
|
100
|
+
}
|
|
101
|
+
this.db.issueSessions.appendIssueSessionEvent({
|
|
102
|
+
projectId: params.project.id,
|
|
103
|
+
linearIssueId: normalizedIssue.id,
|
|
104
|
+
eventType: "undelegated",
|
|
105
|
+
dedupeKey: `undelegated:${normalizedIssue.id}`,
|
|
106
|
+
});
|
|
107
|
+
this.db.issueSessions.clearPendingIssueSessionEventsRespectingActiveLease(params.project.id, normalizedIssue.id);
|
|
108
|
+
this.db.issueSessions.releaseIssueSessionLeaseRespectingActiveLease(params.project.id, normalizedIssue.id);
|
|
109
|
+
this.feed?.publish({
|
|
110
|
+
level: "warn",
|
|
111
|
+
kind: "stage",
|
|
112
|
+
issueKey: issue.issueKey,
|
|
113
|
+
projectId: params.project.id,
|
|
114
|
+
stage: "awaiting_input",
|
|
115
|
+
status: "un_delegated",
|
|
116
|
+
summary: "Issue un-delegated from PatchRelay",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
else if (desiredStage === "implementation"
|
|
120
|
+
&& params.normalized.triggerEvent !== "commentCreated"
|
|
121
|
+
&& params.normalized.triggerEvent !== "commentUpdated"
|
|
122
|
+
&& params.normalized.triggerEvent !== "agentPrompted") {
|
|
123
|
+
this.db.issueSessions.appendIssueSessionEventRespectingActiveLease(params.project.id, normalizedIssue.id, {
|
|
124
|
+
projectId: params.project.id,
|
|
125
|
+
linearIssueId: normalizedIssue.id,
|
|
126
|
+
eventType: "delegated",
|
|
127
|
+
eventJson: JSON.stringify({
|
|
128
|
+
promptContext: params.normalized.agentSession?.promptContext?.trim()
|
|
129
|
+
?? (issue.issueKey ? `Linear issue ${issue.issueKey} was delegated to PatchRelay.` : undefined),
|
|
130
|
+
promptBody: params.normalized.agentSession?.promptBody?.trim(),
|
|
131
|
+
}),
|
|
132
|
+
dedupeKey: `delegated:${normalizedIssue.id}`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
issue: this.db.issueToTrackedIssue(issue),
|
|
137
|
+
wakeRunType: params.peekPendingSessionWakeRunType(params.project.id, normalizedIssue.id),
|
|
138
|
+
delegated,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
isDelegatedToPatchRelay(project, normalized) {
|
|
142
|
+
if (!normalized.issue)
|
|
143
|
+
return false;
|
|
144
|
+
const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
|
|
145
|
+
if (!installation?.actorId)
|
|
146
|
+
return false;
|
|
147
|
+
return normalized.issue.delegateId === installation.actorId;
|
|
148
|
+
}
|
|
149
|
+
async syncIssueDependencies(projectId, issue) {
|
|
150
|
+
let source = issue;
|
|
151
|
+
if (!source.relationsKnown) {
|
|
152
|
+
const linear = await this.linearProvider.forProject(projectId);
|
|
153
|
+
if (linear) {
|
|
154
|
+
try {
|
|
155
|
+
source = mergeIssueMetadata(source, await linear.getIssue(issue.id));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Preserve existing dependency rows when webhook relation data is incomplete.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (source.relationsKnown) {
|
|
163
|
+
this.db.replaceIssueDependencies({
|
|
164
|
+
projectId,
|
|
165
|
+
linearIssueId: source.id,
|
|
166
|
+
blockers: source.blockedBy.map((blocker) => ({
|
|
167
|
+
blockerLinearIssueId: blocker.id,
|
|
168
|
+
...(blocker.identifier ? { blockerIssueKey: blocker.identifier } : {}),
|
|
169
|
+
...(blocker.title ? { blockerTitle: blocker.title } : {}),
|
|
170
|
+
...(blocker.stateName ? { blockerCurrentLinearState: blocker.stateName } : {}),
|
|
171
|
+
...(blocker.stateType ? { blockerCurrentLinearStateType: blocker.stateType } : {}),
|
|
172
|
+
})),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return source;
|
|
176
|
+
}
|
|
177
|
+
}
|