patchrelay 0.35.11 → 0.35.12
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/README.md +41 -9
- package/dist/build-info.json +3 -3
- package/dist/cli/args.js +0 -1
- package/dist/cli/commands/issues.js +2 -56
- package/dist/cli/commands/watch.js +5 -0
- package/dist/cli/data.js +110 -47
- package/dist/cli/formatters/text.js +6 -90
- package/dist/cli/help.js +3 -8
- package/dist/cli/index.js +0 -48
- package/dist/cli/operator-client.js +0 -82
- package/dist/cli/watch/App.js +1 -12
- package/dist/cli/watch/HelpBar.js +2 -2
- package/dist/cli/watch/IssueDetailView.js +57 -26
- package/dist/cli/watch/IssueRow.js +71 -27
- package/dist/cli/watch/StatusBar.js +7 -4
- package/dist/cli/watch/state-visualization.js +48 -23
- package/dist/cli/watch/timeline-builder.js +2 -1
- package/dist/cli/watch/use-detail-stream.js +10 -104
- package/dist/cli/watch/use-watch-stream.js +11 -102
- package/dist/cli/watch/watch-state.js +18 -50
- package/dist/codex-thread-utils.js +3 -0
- package/dist/db/migrations.js +239 -2
- package/dist/db.js +628 -39
- package/dist/github-app-token.js +7 -0
- package/dist/github-failure-context.js +44 -1
- package/dist/github-rollup.js +47 -0
- package/dist/github-webhook-handler.js +248 -51
- package/dist/github-webhooks.js +5 -0
- package/dist/http.js +12 -264
- package/dist/idle-reconciliation.js +268 -76
- package/dist/issue-query-service.js +221 -129
- package/dist/issue-session-events.js +151 -0
- package/dist/issue-session.js +99 -0
- package/dist/linear-client.js +39 -25
- package/dist/linear-session-reporting.js +12 -0
- package/dist/linear-session-sync.js +253 -24
- package/dist/linear-workflow.js +33 -0
- package/dist/merge-queue-protocol.js +0 -51
- package/dist/preflight.js +1 -4
- package/dist/queue-health-monitor.js +11 -7
- package/dist/run-orchestrator.js +1295 -146
- package/dist/run-reporting.js +5 -3
- package/dist/service.js +279 -102
- package/dist/status-note.js +56 -0
- package/dist/waiting-reason.js +65 -0
- package/dist/webhook-handler.js +270 -79
- package/package.json +1 -1
- package/dist/cli/commands/feed.js +0 -60
- package/dist/cli/watch/FeedView.js +0 -28
- package/dist/cli/watch/use-feed-stream.js +0 -92
package/dist/webhook-handler.js
CHANGED
|
@@ -2,10 +2,12 @@ import { buildAgentSessionPlanForIssue, } from "./agent-session-plan.js";
|
|
|
2
2
|
import { buildAgentSessionExternalUrls } from "./agent-session-presentation.js";
|
|
3
3
|
import { TERMINAL_STATES } from "./factory-state.js";
|
|
4
4
|
import { buildAlreadyRunningThought, buildDelegationThought, buildPromptDeliveredThought, buildStopConfirmationActivity, } from "./linear-session-reporting.js";
|
|
5
|
+
import { deriveIssueStatusNote } from "./status-note.js";
|
|
5
6
|
import { resolveProject, triggerEventAllowed, trustedActorAllowed } from "./project-resolution.js";
|
|
6
7
|
import { normalizeWebhook } from "./webhooks.js";
|
|
7
8
|
import { InstallationWebhookHandler } from "./webhook-installation-handler.js";
|
|
8
9
|
import { safeJsonParse, sanitizeDiagnosticText } from "./utils.js";
|
|
10
|
+
import { extractLatestAssistantSummary } from "./issue-session-events.js";
|
|
9
11
|
export class WebhookHandler {
|
|
10
12
|
config;
|
|
11
13
|
db;
|
|
@@ -101,27 +103,51 @@ export class WebhookHandler {
|
|
|
101
103
|
// Handle issue removal: release active runs, mark as failed.
|
|
102
104
|
if (hydrated.triggerEvent === "issueRemoved" && trackedIssue) {
|
|
103
105
|
const removedIssue = this.db.getIssue(project.id, issue.id);
|
|
106
|
+
const activeLease = this.db.getActiveIssueSessionLease(project.id, issue.id);
|
|
107
|
+
const commitRemoval = () => {
|
|
108
|
+
if (removedIssue?.activeRunId) {
|
|
109
|
+
const run = this.db.getRun(removedIssue.activeRunId);
|
|
110
|
+
if (run) {
|
|
111
|
+
this.db.finishRun(run.id, { status: "released", failureReason: "Issue removed from Linear" });
|
|
112
|
+
}
|
|
113
|
+
return this.db.upsertIssue({
|
|
114
|
+
projectId: project.id,
|
|
115
|
+
linearIssueId: issue.id,
|
|
116
|
+
activeRunId: null,
|
|
117
|
+
pendingRunType: null,
|
|
118
|
+
factoryState: "failed",
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
if (removedIssue && !TERMINAL_STATES.has(removedIssue.factoryState)) {
|
|
122
|
+
return this.db.upsertIssue({
|
|
123
|
+
projectId: project.id,
|
|
124
|
+
linearIssueId: issue.id,
|
|
125
|
+
pendingRunType: null,
|
|
126
|
+
factoryState: "failed",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return removedIssue;
|
|
130
|
+
};
|
|
104
131
|
if (removedIssue?.activeRunId) {
|
|
105
132
|
const run = this.db.getRun(removedIssue.activeRunId);
|
|
106
133
|
if (run) {
|
|
107
|
-
this.
|
|
134
|
+
await this.stopActiveRun(run, "STOP: The Linear issue was removed. Stop working immediately and exit.");
|
|
108
135
|
}
|
|
109
|
-
this.db.upsertIssue({
|
|
110
|
-
projectId: project.id,
|
|
111
|
-
linearIssueId: issue.id,
|
|
112
|
-
activeRunId: null,
|
|
113
|
-
pendingRunType: null,
|
|
114
|
-
factoryState: "failed",
|
|
115
|
-
});
|
|
116
136
|
}
|
|
117
|
-
|
|
118
|
-
this.db.
|
|
119
|
-
projectId: project.id,
|
|
120
|
-
linearIssueId: issue.id,
|
|
121
|
-
pendingRunType: null,
|
|
122
|
-
factoryState: "failed",
|
|
123
|
-
});
|
|
137
|
+
if (activeLease) {
|
|
138
|
+
this.db.withIssueSessionLease(project.id, issue.id, activeLease.leaseId, commitRemoval);
|
|
124
139
|
}
|
|
140
|
+
else {
|
|
141
|
+
commitRemoval();
|
|
142
|
+
}
|
|
143
|
+
this.db.appendIssueSessionEvent({
|
|
144
|
+
projectId: project.id,
|
|
145
|
+
linearIssueId: issue.id,
|
|
146
|
+
eventType: "issue_removed",
|
|
147
|
+
dedupeKey: `issue_removed:${issue.id}`,
|
|
148
|
+
});
|
|
149
|
+
this.db.clearPendingIssueSessionEventsRespectingActiveLease(project.id, issue.id);
|
|
150
|
+
this.db.releaseIssueSessionLeaseRespectingActiveLease(project.id, issue.id);
|
|
125
151
|
this.feed?.publish({
|
|
126
152
|
level: "warn",
|
|
127
153
|
kind: "stage",
|
|
@@ -133,36 +159,39 @@ export class WebhookHandler {
|
|
|
133
159
|
});
|
|
134
160
|
}
|
|
135
161
|
// Handle agent session events
|
|
136
|
-
await this.handleAgentSession(hydrated, project, trackedIssue, result.
|
|
162
|
+
await this.handleAgentSession(hydrated, project, trackedIssue, result.wakeRunType, result.delegated);
|
|
137
163
|
// Handle comments during active run
|
|
138
164
|
await this.handleComment(hydrated, project, trackedIssue);
|
|
139
165
|
this.db.markWebhookProcessed(webhookEventId, "processed");
|
|
140
|
-
|
|
166
|
+
const wakeAlreadyQueuedByFollowUpHandler = normalized.triggerEvent === "commentCreated"
|
|
167
|
+
|| normalized.triggerEvent === "commentUpdated"
|
|
168
|
+
|| normalized.triggerEvent === "agentPrompted";
|
|
169
|
+
if (result.wakeRunType && !wakeAlreadyQueuedByFollowUpHandler) {
|
|
170
|
+
const queuedRunType = this.enqueuePendingSessionWake(project.id, issue.id);
|
|
141
171
|
this.feed?.publish({
|
|
142
172
|
level: "info",
|
|
143
173
|
kind: "stage",
|
|
144
174
|
issueKey: issue.identifier,
|
|
145
175
|
projectId: project.id,
|
|
146
|
-
stage: result.
|
|
176
|
+
stage: queuedRunType ?? result.wakeRunType,
|
|
147
177
|
status: "queued",
|
|
148
|
-
summary: `Queued ${result.
|
|
178
|
+
summary: `Queued ${(queuedRunType ?? result.wakeRunType)} workflow`,
|
|
149
179
|
detail: `Triggered by ${hydrated.triggerEvent}.`,
|
|
150
180
|
});
|
|
151
|
-
this.enqueueIssue(project.id, issue.id);
|
|
152
181
|
}
|
|
153
182
|
for (const dependentIssueId of newlyReadyDependents) {
|
|
154
183
|
const dependent = this.db.getTrackedIssue(project.id, dependentIssueId);
|
|
184
|
+
const queuedRunType = this.enqueuePendingSessionWake(project.id, dependentIssueId);
|
|
155
185
|
this.feed?.publish({
|
|
156
186
|
level: "info",
|
|
157
187
|
kind: "stage",
|
|
158
188
|
issueKey: dependent?.issueKey,
|
|
159
189
|
projectId: project.id,
|
|
160
|
-
stage: "implementation",
|
|
190
|
+
stage: queuedRunType ?? "implementation",
|
|
161
191
|
status: "queued",
|
|
162
|
-
summary:
|
|
192
|
+
summary: `Queued ${(queuedRunType ?? "implementation")} after blockers resolved`,
|
|
163
193
|
detail: `All blockers are now done for ${dependent?.issueKey ?? dependentIssueId}.`,
|
|
164
194
|
});
|
|
165
|
-
this.enqueueIssue(project.id, dependentIssueId);
|
|
166
195
|
}
|
|
167
196
|
}
|
|
168
197
|
catch (error) {
|
|
@@ -183,26 +212,28 @@ export class WebhookHandler {
|
|
|
183
212
|
async recordDesiredStage(project, normalized) {
|
|
184
213
|
const normalizedIssue = normalized.issue;
|
|
185
214
|
if (!normalizedIssue) {
|
|
186
|
-
return { issue: undefined,
|
|
215
|
+
return { issue: undefined, wakeRunType: undefined, delegated: false };
|
|
187
216
|
}
|
|
188
217
|
// ── 1. Fetch data ────────────────────────────────────────────
|
|
189
218
|
const existingIssue = this.db.getIssue(project.id, normalizedIssue.id);
|
|
190
219
|
const activeRun = existingIssue?.activeRunId ? this.db.getRun(existingIssue.activeRunId) : undefined;
|
|
191
220
|
const delegated = this.isDelegatedToPatchRelay(project, normalized);
|
|
192
221
|
const triggerAllowed = triggerEventAllowed(project, normalized.triggerEvent);
|
|
193
|
-
|
|
194
|
-
|
|
222
|
+
const incomingAgentSessionId = normalized.agentSession?.id;
|
|
223
|
+
const hasPendingWake = this.db.peekIssueSessionWake(project.id, normalizedIssue.id) !== undefined;
|
|
224
|
+
if (!existingIssue && !delegated && !incomingAgentSessionId) {
|
|
225
|
+
return { issue: undefined, wakeRunType: undefined, delegated };
|
|
195
226
|
}
|
|
196
227
|
const hydratedIssue = await this.syncIssueDependencies(project.id, normalizedIssue);
|
|
197
228
|
const unresolvedBlockers = this.db.countUnresolvedBlockers(project.id, normalizedIssue.id);
|
|
198
|
-
const pendingRunContextJson = mergePendingImplementationContext(existingIssue?.pendingRunContextJson, normalized);
|
|
199
229
|
const terminal = isTerminalDelegationState(existingIssue, hydratedIssue);
|
|
200
230
|
// ── 2. Pure decisions ────────────────────────────────────────
|
|
201
|
-
const
|
|
202
|
-
delegated, triggerAllowed, unresolvedBlockers,
|
|
231
|
+
const desiredStage = decideRunIntent({
|
|
232
|
+
delegated, triggerAllowed, triggerEvent: normalized.triggerEvent, unresolvedBlockers,
|
|
203
233
|
hasActiveRun: Boolean(activeRun),
|
|
204
|
-
|
|
234
|
+
hasPendingWake,
|
|
205
235
|
terminal,
|
|
236
|
+
currentState: existingIssue?.factoryState,
|
|
206
237
|
});
|
|
207
238
|
const runRelease = decideActiveRunRelease({
|
|
208
239
|
hasActiveRun: Boolean(activeRun),
|
|
@@ -215,17 +246,20 @@ export class WebhookHandler {
|
|
|
215
246
|
delegated,
|
|
216
247
|
currentState: existingIssue?.factoryState,
|
|
217
248
|
});
|
|
218
|
-
const
|
|
249
|
+
const delegatedStateRecovery = delegated
|
|
250
|
+
&& !terminal
|
|
251
|
+
&& existingIssue?.factoryState === "awaiting_input"
|
|
252
|
+
&& !undelegation.factoryState;
|
|
253
|
+
const existingWakeRunType = existingIssue ? this.peekPendingSessionWakeRunType(project.id, normalizedIssue.id) : undefined;
|
|
254
|
+
const clearPending = (unresolvedBlockers > 0 && existingWakeRunType === "implementation" && !activeRun)
|
|
219
255
|
|| undelegation.clearPending;
|
|
220
256
|
const agentSessionId = decideAgentSession({
|
|
221
257
|
sessionId: normalized.agentSession?.id,
|
|
222
|
-
hasActiveRun: Boolean(activeRun),
|
|
223
|
-
hasPendingRun: Boolean(pendingRunType),
|
|
224
258
|
triggerEvent: normalized.triggerEvent,
|
|
225
259
|
delegated,
|
|
226
260
|
});
|
|
227
261
|
// ── 3. Transactional commit ──────────────────────────────────
|
|
228
|
-
const
|
|
262
|
+
const commitIssueUpdate = () => {
|
|
229
263
|
const record = this.db.upsertIssue({
|
|
230
264
|
projectId: project.id,
|
|
231
265
|
linearIssueId: normalizedIssue.id,
|
|
@@ -237,11 +271,10 @@ export class WebhookHandler {
|
|
|
237
271
|
...(hydratedIssue.estimate != null ? { estimate: hydratedIssue.estimate } : {}),
|
|
238
272
|
...(hydratedIssue.stateName ? { currentLinearState: hydratedIssue.stateName } : {}),
|
|
239
273
|
...(hydratedIssue.stateType ? { currentLinearStateType: hydratedIssue.stateType } : {}),
|
|
240
|
-
...(
|
|
241
|
-
...(
|
|
242
|
-
...(
|
|
243
|
-
|
|
244
|
-
: {}),
|
|
274
|
+
...(!existingIssue && !delegated && incomingAgentSessionId ? { factoryState: "awaiting_input" } : {}),
|
|
275
|
+
...(delegatedStateRecovery ? { factoryState: "delegated" } : {}),
|
|
276
|
+
...(desiredStage ? { pendingRunType: null, pendingRunContextJson: null, factoryState: "delegated" } : {}),
|
|
277
|
+
...(clearPending ? { pendingRunType: null, pendingRunContextJson: null } : {}),
|
|
245
278
|
...(agentSessionId !== undefined ? { agentSessionId } : {}),
|
|
246
279
|
...(runRelease.release ? { activeRunId: null } : {}),
|
|
247
280
|
...(undelegation.factoryState ? { factoryState: undelegation.factoryState } : {}),
|
|
@@ -250,9 +283,28 @@ export class WebhookHandler {
|
|
|
250
283
|
this.db.finishRun(activeRun.id, { status: "released", failureReason: runRelease.reason });
|
|
251
284
|
}
|
|
252
285
|
return record;
|
|
253
|
-
}
|
|
286
|
+
};
|
|
287
|
+
const activeLease = this.db.getActiveIssueSessionLease(project.id, normalizedIssue.id);
|
|
288
|
+
const issue = activeLease
|
|
289
|
+
? this.db.withIssueSessionLease(project.id, normalizedIssue.id, activeLease.leaseId, commitIssueUpdate) ?? (existingIssue ?? this.db.upsertIssue({
|
|
290
|
+
projectId: project.id,
|
|
291
|
+
linearIssueId: normalizedIssue.id,
|
|
292
|
+
...(hydratedIssue.identifier ? { issueKey: hydratedIssue.identifier } : {}),
|
|
293
|
+
}))
|
|
294
|
+
: this.db.transaction(commitIssueUpdate);
|
|
254
295
|
// ── 4. Side effects (after transaction) ──────────────────────
|
|
255
296
|
if (undelegation.factoryState) {
|
|
297
|
+
if (activeRun?.threadId && activeRun.turnId) {
|
|
298
|
+
await this.stopActiveRun(activeRun, "STOP: The issue was un-delegated from PatchRelay. Stop working immediately and exit.");
|
|
299
|
+
}
|
|
300
|
+
this.db.appendIssueSessionEvent({
|
|
301
|
+
projectId: project.id,
|
|
302
|
+
linearIssueId: normalizedIssue.id,
|
|
303
|
+
eventType: "undelegated",
|
|
304
|
+
dedupeKey: `undelegated:${normalizedIssue.id}`,
|
|
305
|
+
});
|
|
306
|
+
this.db.clearPendingIssueSessionEventsRespectingActiveLease(project.id, normalizedIssue.id);
|
|
307
|
+
this.db.releaseIssueSessionLeaseRespectingActiveLease(project.id, normalizedIssue.id);
|
|
256
308
|
this.feed?.publish({
|
|
257
309
|
level: "warn",
|
|
258
310
|
kind: "stage",
|
|
@@ -263,9 +315,25 @@ export class WebhookHandler {
|
|
|
263
315
|
summary: "Issue un-delegated from PatchRelay",
|
|
264
316
|
});
|
|
265
317
|
}
|
|
318
|
+
else if (desiredStage === "implementation"
|
|
319
|
+
&& normalized.triggerEvent !== "commentCreated"
|
|
320
|
+
&& normalized.triggerEvent !== "commentUpdated"
|
|
321
|
+
&& normalized.triggerEvent !== "agentPrompted") {
|
|
322
|
+
this.db.appendIssueSessionEventRespectingActiveLease(project.id, normalizedIssue.id, {
|
|
323
|
+
projectId: project.id,
|
|
324
|
+
linearIssueId: normalizedIssue.id,
|
|
325
|
+
eventType: "delegated",
|
|
326
|
+
eventJson: JSON.stringify({
|
|
327
|
+
promptContext: normalized.agentSession?.promptContext?.trim()
|
|
328
|
+
?? (issue.issueKey ? `Linear issue ${issue.issueKey} was delegated to PatchRelay.` : undefined),
|
|
329
|
+
promptBody: normalized.agentSession?.promptBody?.trim(),
|
|
330
|
+
}),
|
|
331
|
+
dedupeKey: `delegated:${normalizedIssue.id}`,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
266
334
|
return {
|
|
267
335
|
issue: this.db.issueToTrackedIssue(issue),
|
|
268
|
-
|
|
336
|
+
wakeRunType: this.peekPendingSessionWakeRunType(project.id, normalizedIssue.id),
|
|
269
337
|
delegated,
|
|
270
338
|
};
|
|
271
339
|
}
|
|
@@ -314,29 +382,41 @@ export class WebhookHandler {
|
|
|
314
382
|
}
|
|
315
383
|
const unresolved = this.db.countUnresolvedBlockers(projectId, dependent.linearIssueId);
|
|
316
384
|
if (unresolved > 0) {
|
|
317
|
-
if (
|
|
385
|
+
if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation"
|
|
386
|
+
&& issue.activeRunId === undefined
|
|
387
|
+
&& !this.db.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
|
|
318
388
|
this.db.upsertIssue({
|
|
319
389
|
projectId,
|
|
320
390
|
linearIssueId: dependent.linearIssueId,
|
|
321
391
|
pendingRunType: null,
|
|
392
|
+
pendingRunContextJson: null,
|
|
322
393
|
});
|
|
323
394
|
}
|
|
324
395
|
continue;
|
|
325
396
|
}
|
|
326
|
-
if (issue.factoryState !== "delegated" || issue.activeRunId !== undefined ||
|
|
397
|
+
if (issue.factoryState !== "delegated" || issue.activeRunId !== undefined || this.db.hasPendingIssueSessionEvents(projectId, dependent.linearIssueId)) {
|
|
327
398
|
continue;
|
|
328
399
|
}
|
|
329
|
-
this.
|
|
400
|
+
if (this.peekPendingSessionWakeRunType(projectId, dependent.linearIssueId) === "implementation") {
|
|
401
|
+
this.db.upsertIssue({
|
|
402
|
+
projectId,
|
|
403
|
+
linearIssueId: dependent.linearIssueId,
|
|
404
|
+
pendingRunType: null,
|
|
405
|
+
pendingRunContextJson: null,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
this.db.appendIssueSessionEventRespectingActiveLease(projectId, dependent.linearIssueId, {
|
|
330
409
|
projectId,
|
|
331
410
|
linearIssueId: dependent.linearIssueId,
|
|
332
|
-
|
|
411
|
+
eventType: "delegated",
|
|
412
|
+
dedupeKey: `delegated:${dependent.linearIssueId}`,
|
|
333
413
|
});
|
|
334
414
|
newlyReady.push(dependent.linearIssueId);
|
|
335
415
|
}
|
|
336
416
|
return newlyReady;
|
|
337
417
|
}
|
|
338
418
|
// ─── Agent session handling (inlined) ─────────────────────────────
|
|
339
|
-
async handleAgentSession(normalized, project, trackedIssue,
|
|
419
|
+
async handleAgentSession(normalized, project, trackedIssue, wakeRunType, delegated) {
|
|
340
420
|
if (!normalized.agentSession?.id || !normalized.issue)
|
|
341
421
|
return;
|
|
342
422
|
const linear = await this.linearProvider.forProject(project.id);
|
|
@@ -346,14 +426,16 @@ export class WebhookHandler {
|
|
|
346
426
|
const activeRun = existingIssue?.activeRunId ? this.db.getRun(existingIssue.activeRunId) : undefined;
|
|
347
427
|
if (normalized.triggerEvent === "agentSessionCreated") {
|
|
348
428
|
if (!delegated) {
|
|
349
|
-
const
|
|
350
|
-
|
|
429
|
+
const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
430
|
+
if (latestIssue ?? trackedIssue) {
|
|
431
|
+
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue);
|
|
432
|
+
}
|
|
351
433
|
return;
|
|
352
434
|
}
|
|
353
|
-
if (
|
|
435
|
+
if (wakeRunType) {
|
|
354
436
|
const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
355
|
-
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, { pendingRunType:
|
|
356
|
-
await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(
|
|
437
|
+
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, { pendingRunType: wakeRunType });
|
|
438
|
+
await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(wakeRunType));
|
|
357
439
|
return;
|
|
358
440
|
}
|
|
359
441
|
if (activeRun) {
|
|
@@ -411,10 +493,32 @@ export class WebhookHandler {
|
|
|
411
493
|
await this.publishAgentActivity(linear, normalized.agentSession.id, buildPromptDeliveredThought(activeRun.runType), { ephemeral: true });
|
|
412
494
|
return;
|
|
413
495
|
}
|
|
414
|
-
if (
|
|
496
|
+
if (promptBody && existingIssue && (delegated || existingIssue.factoryState === "awaiting_input")) {
|
|
497
|
+
const hadPendingWake = this.db.peekIssueSessionWake(project.id, normalized.issue.id) !== undefined;
|
|
498
|
+
const directReply = this.isDirectReplyToOutstandingQuestion(existingIssue);
|
|
499
|
+
this.db.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
|
|
500
|
+
projectId: project.id,
|
|
501
|
+
linearIssueId: normalized.issue.id,
|
|
502
|
+
eventType: directReply ? "direct_reply" : "followup_prompt",
|
|
503
|
+
eventJson: JSON.stringify({
|
|
504
|
+
text: promptBody,
|
|
505
|
+
source: "linear_agent_prompt",
|
|
506
|
+
}),
|
|
507
|
+
});
|
|
508
|
+
const queuedRunType = hadPendingWake
|
|
509
|
+
? this.peekPendingSessionWakeRunType(project.id, normalized.issue.id)
|
|
510
|
+
: this.enqueuePendingSessionWake(project.id, normalized.issue.id);
|
|
511
|
+
const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
512
|
+
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, {
|
|
513
|
+
pendingRunType: queuedRunType ?? wakeRunType ?? (existingIssue.prReviewState === "changes_requested" ? "review_fix" : "implementation"),
|
|
514
|
+
});
|
|
515
|
+
await this.publishAgentActivity(linear, normalized.agentSession.id, buildPromptDeliveredThought(queuedRunType ?? wakeRunType ?? "implementation"), { ephemeral: true });
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (wakeRunType) {
|
|
415
519
|
const latestIssue = this.db.getIssue(project.id, normalized.issue.id);
|
|
416
|
-
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, { pendingRunType:
|
|
417
|
-
await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(
|
|
520
|
+
await this.syncAgentSession(linear, normalized.agentSession.id, latestIssue ?? trackedIssue, { pendingRunType: wakeRunType });
|
|
521
|
+
await this.publishAgentActivity(linear, normalized.agentSession.id, buildDelegationThought(wakeRunType, "prompt"), { ephemeral: true });
|
|
418
522
|
}
|
|
419
523
|
}
|
|
420
524
|
// ─── Stop signal handling ────────────────────────────────────────
|
|
@@ -435,13 +539,21 @@ export class WebhookHandler {
|
|
|
435
539
|
}
|
|
436
540
|
this.db.finishRun(activeRun.id, { status: "released", threadId: activeRun.threadId, turnId: activeRun.turnId });
|
|
437
541
|
}
|
|
438
|
-
this.db.
|
|
542
|
+
this.db.upsertIssueRespectingActiveLease(project.id, issueId, {
|
|
439
543
|
projectId: project.id,
|
|
440
544
|
linearIssueId: issueId,
|
|
441
545
|
activeRunId: null,
|
|
442
546
|
factoryState: "awaiting_input",
|
|
443
547
|
agentSessionId: sessionId,
|
|
444
548
|
});
|
|
549
|
+
this.db.appendIssueSessionEvent({
|
|
550
|
+
projectId: project.id,
|
|
551
|
+
linearIssueId: issueId,
|
|
552
|
+
eventType: "stop_requested",
|
|
553
|
+
dedupeKey: `stop_requested:${issueId}`,
|
|
554
|
+
});
|
|
555
|
+
this.db.clearPendingIssueSessionEventsRespectingActiveLease(project.id, issueId);
|
|
556
|
+
this.db.releaseIssueSessionLeaseRespectingActiveLease(project.id, issueId);
|
|
445
557
|
this.feed?.publish({
|
|
446
558
|
level: "info",
|
|
447
559
|
kind: "agent",
|
|
@@ -454,6 +566,16 @@ export class WebhookHandler {
|
|
|
454
566
|
await this.publishAgentActivity(linear, sessionId, buildStopConfirmationActivity());
|
|
455
567
|
await this.syncAgentSession(linear, sessionId, updatedIssue ?? trackedIssue);
|
|
456
568
|
}
|
|
569
|
+
async stopActiveRun(run, input) {
|
|
570
|
+
if (!run.threadId || !run.turnId)
|
|
571
|
+
return;
|
|
572
|
+
try {
|
|
573
|
+
await this.codex.steerTurn({ threadId: run.threadId, turnId: run.turnId, input });
|
|
574
|
+
}
|
|
575
|
+
catch (error) {
|
|
576
|
+
this.logger.warn({ runId: run.id, error: error instanceof Error ? error.message : String(error) }, "Failed to steer active run during session shutdown");
|
|
577
|
+
}
|
|
578
|
+
}
|
|
457
579
|
// ─── Comment handling (inlined) ───────────────────────────────────
|
|
458
580
|
async handleComment(normalized, project, trackedIssue) {
|
|
459
581
|
if ((normalized.triggerEvent !== "commentCreated" && normalized.triggerEvent !== "commentUpdated") ||
|
|
@@ -468,6 +590,15 @@ export class WebhookHandler {
|
|
|
468
590
|
// commentCreated webhook back — without this guard that re-enqueues a new run.
|
|
469
591
|
const installation = this.db.linearInstallations.getLinearInstallationForProject(project.id);
|
|
470
592
|
if (installation?.actorId && normalized.actor?.id === installation.actorId) {
|
|
593
|
+
this.db.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
|
|
594
|
+
projectId: project.id,
|
|
595
|
+
linearIssueId: normalized.issue.id,
|
|
596
|
+
eventType: "self_comment",
|
|
597
|
+
eventJson: JSON.stringify({
|
|
598
|
+
body: normalized.comment.body.trim(),
|
|
599
|
+
author: normalized.comment.userName,
|
|
600
|
+
}),
|
|
601
|
+
});
|
|
471
602
|
return;
|
|
472
603
|
}
|
|
473
604
|
const issue = this.db.getIssue(project.id, normalized.issue.id);
|
|
@@ -475,23 +606,30 @@ export class WebhookHandler {
|
|
|
475
606
|
return;
|
|
476
607
|
// No active run — enqueue a run with the comment as context if appropriate
|
|
477
608
|
if (!issue.activeRunId) {
|
|
478
|
-
const ENQUEUEABLE_STATES = new Set(["pr_open", "changes_requested", "implementing", "delegated"]);
|
|
609
|
+
const ENQUEUEABLE_STATES = new Set(["pr_open", "changes_requested", "implementing", "delegated", "awaiting_input"]);
|
|
479
610
|
if (ENQUEUEABLE_STATES.has(issue.factoryState)) {
|
|
480
611
|
const runType = issue.prReviewState === "changes_requested" ? "review_fix" : "implementation";
|
|
481
|
-
this.db.
|
|
612
|
+
const hadPendingWake = this.db.peekIssueSessionWake(project.id, normalized.issue.id) !== undefined;
|
|
613
|
+
const directReply = this.isDirectReplyToOutstandingQuestion(issue);
|
|
614
|
+
this.db.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
|
|
482
615
|
projectId: project.id,
|
|
483
616
|
linearIssueId: normalized.issue.id,
|
|
484
|
-
|
|
485
|
-
|
|
617
|
+
eventType: directReply ? "direct_reply" : "followup_comment",
|
|
618
|
+
eventJson: JSON.stringify({
|
|
619
|
+
body: normalized.comment.body.trim(),
|
|
620
|
+
author: normalized.comment.userName,
|
|
621
|
+
}),
|
|
486
622
|
});
|
|
487
|
-
|
|
623
|
+
const queuedRunType = hadPendingWake
|
|
624
|
+
? this.peekPendingSessionWakeRunType(project.id, normalized.issue.id)
|
|
625
|
+
: this.enqueuePendingSessionWake(project.id, normalized.issue.id);
|
|
488
626
|
this.feed?.publish({
|
|
489
627
|
level: "info",
|
|
490
628
|
kind: "comment",
|
|
491
629
|
projectId: project.id,
|
|
492
630
|
issueKey: trackedIssue?.issueKey,
|
|
493
631
|
status: "enqueued",
|
|
494
|
-
summary: `Comment enqueued ${runType} run`,
|
|
632
|
+
summary: `Comment enqueued ${(queuedRunType ?? runType)} run`,
|
|
495
633
|
detail: normalized.comment.body.slice(0, 200),
|
|
496
634
|
});
|
|
497
635
|
}
|
|
@@ -520,6 +658,20 @@ export class WebhookHandler {
|
|
|
520
658
|
}
|
|
521
659
|
catch (error) {
|
|
522
660
|
this.logger.warn({ issueKey: trackedIssue?.issueKey, error: error instanceof Error ? error.message : String(error) }, "Failed to deliver follow-up comment");
|
|
661
|
+
const hadPendingWake = this.db.hasPendingIssueSessionEvents(project.id, normalized.issue.id);
|
|
662
|
+
const directReply = this.isDirectReplyToOutstandingQuestion(issue);
|
|
663
|
+
this.db.appendIssueSessionEventRespectingActiveLease(project.id, normalized.issue.id, {
|
|
664
|
+
projectId: project.id,
|
|
665
|
+
linearIssueId: normalized.issue.id,
|
|
666
|
+
eventType: directReply ? "direct_reply" : "followup_comment",
|
|
667
|
+
eventJson: JSON.stringify({
|
|
668
|
+
body: normalized.comment.body.trim(),
|
|
669
|
+
author: normalized.comment.userName,
|
|
670
|
+
}),
|
|
671
|
+
});
|
|
672
|
+
if (!hadPendingWake) {
|
|
673
|
+
this.enqueuePendingSessionWake(project.id, normalized.issue.id);
|
|
674
|
+
}
|
|
523
675
|
this.feed?.publish({
|
|
524
676
|
level: "warn",
|
|
525
677
|
kind: "comment",
|
|
@@ -531,6 +683,17 @@ export class WebhookHandler {
|
|
|
531
683
|
});
|
|
532
684
|
}
|
|
533
685
|
}
|
|
686
|
+
peekPendingSessionWakeRunType(projectId, issueId) {
|
|
687
|
+
return this.db.peekIssueSessionWake(projectId, issueId)?.runType;
|
|
688
|
+
}
|
|
689
|
+
enqueuePendingSessionWake(projectId, issueId) {
|
|
690
|
+
const wake = this.db.peekIssueSessionWake(projectId, issueId);
|
|
691
|
+
if (!wake) {
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
this.enqueueIssue(projectId, issueId);
|
|
695
|
+
return wake.runType;
|
|
696
|
+
}
|
|
534
697
|
// ─── Helpers ──────────────────────────────────────────────────────
|
|
535
698
|
async publishAgentActivity(linear, agentSessionId, content, options) {
|
|
536
699
|
try {
|
|
@@ -544,6 +707,18 @@ export class WebhookHandler {
|
|
|
544
707
|
this.logger.warn({ agentSessionId, error: error instanceof Error ? error.message : String(error) }, "Failed to publish Linear agent activity");
|
|
545
708
|
}
|
|
546
709
|
}
|
|
710
|
+
async isCurrentLinearIssueDelegatedToPatchRelay(linear, projectId, issueId) {
|
|
711
|
+
const installation = this.db.linearInstallations.getLinearInstallationForProject(projectId);
|
|
712
|
+
if (!installation?.actorId)
|
|
713
|
+
return false;
|
|
714
|
+
try {
|
|
715
|
+
const issue = await linear.getIssue(issueId);
|
|
716
|
+
return issue.delegateId === installation.actorId;
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
547
722
|
async syncAgentSession(linear, agentSessionId, issue, options) {
|
|
548
723
|
if (!linear.updateAgentSession)
|
|
549
724
|
return;
|
|
@@ -560,7 +735,7 @@ export class WebhookHandler {
|
|
|
560
735
|
? {
|
|
561
736
|
plan: buildAgentSessionPlanForIssue({
|
|
562
737
|
factoryState: issue.factoryState,
|
|
563
|
-
pendingRunType: options?.pendingRunType ?? (
|
|
738
|
+
pendingRunType: options?.pendingRunType ?? this.peekPendingSessionWakeRunType(issue.projectId, issue.linearIssueId),
|
|
564
739
|
ciRepairAttempts: "ciRepairAttempts" in issue ? issue.ciRepairAttempts : 0,
|
|
565
740
|
queueRepairAttempts: "queueRepairAttempts" in issue ? issue.queueRepairAttempts : 0,
|
|
566
741
|
}, options?.activeRunType ? { activeRunType: options.activeRunType } : undefined),
|
|
@@ -611,11 +786,40 @@ export class WebhookHandler {
|
|
|
611
786
|
}
|
|
612
787
|
return undefined;
|
|
613
788
|
}
|
|
789
|
+
isDirectReplyToOutstandingQuestion(issue) {
|
|
790
|
+
if (!issue)
|
|
791
|
+
return false;
|
|
792
|
+
const linearNeedsInput = issue.currentLinearState?.trim().toLowerCase().includes("input") ?? false;
|
|
793
|
+
if (issue.factoryState !== "awaiting_input" && !linearNeedsInput)
|
|
794
|
+
return false;
|
|
795
|
+
if (issue.threadId) {
|
|
796
|
+
return true;
|
|
797
|
+
}
|
|
798
|
+
const latestRun = this.db.getLatestRunForIssue(issue.projectId, issue.linearIssueId);
|
|
799
|
+
const latestRunNote = extractLatestAssistantSummary(latestRun)?.trim();
|
|
800
|
+
if (latestRunNote?.endsWith("?")) {
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
const latestEvent = this.db.listIssueSessionEvents(issue.projectId, issue.linearIssueId).at(-1);
|
|
804
|
+
const statusNote = deriveIssueStatusNote({
|
|
805
|
+
issue,
|
|
806
|
+
latestRun,
|
|
807
|
+
latestEvent,
|
|
808
|
+
waitingReason: undefined,
|
|
809
|
+
})?.trim();
|
|
810
|
+
return Boolean(statusNote?.endsWith("?"));
|
|
811
|
+
}
|
|
614
812
|
}
|
|
615
813
|
// ─── Pure decision functions for recordDesiredStage ──────────────
|
|
616
814
|
function decideRunIntent(p) {
|
|
617
|
-
|
|
618
|
-
|
|
815
|
+
const wakeEligibleState = p.currentState === undefined
|
|
816
|
+
|| p.currentState === "delegated"
|
|
817
|
+
|| p.currentState === "awaiting_input";
|
|
818
|
+
const delegatedStartupRecovery = p.delegated
|
|
819
|
+
&& p.currentState === "awaiting_input"
|
|
820
|
+
&& p.triggerEvent === "issueCreated";
|
|
821
|
+
if (p.delegated && (p.triggerAllowed || delegatedStartupRecovery) && p.unresolvedBlockers === 0
|
|
822
|
+
&& !p.hasActiveRun && !p.hasPendingWake && !p.terminal && wakeEligibleState) {
|
|
619
823
|
return "implementation";
|
|
620
824
|
}
|
|
621
825
|
return undefined;
|
|
@@ -642,7 +846,7 @@ function decideUnDelegation(p) {
|
|
|
642
846
|
function decideAgentSession(p) {
|
|
643
847
|
if (p.sessionId)
|
|
644
848
|
return p.sessionId;
|
|
645
|
-
if (
|
|
849
|
+
if (p.triggerEvent === "delegateChanged" && !p.delegated)
|
|
646
850
|
return null;
|
|
647
851
|
return undefined;
|
|
648
852
|
}
|
|
@@ -654,7 +858,7 @@ function isTerminalDelegationState(existingIssue, hydratedIssue) {
|
|
|
654
858
|
if (existingIssue?.prState === "merged") {
|
|
655
859
|
return true;
|
|
656
860
|
}
|
|
657
|
-
if (existingIssue?.factoryState && TERMINAL_STATES.has(existingIssue.factoryState)) {
|
|
861
|
+
if (existingIssue?.factoryState && existingIssue.factoryState !== "awaiting_input" && TERMINAL_STATES.has(existingIssue.factoryState)) {
|
|
658
862
|
return true;
|
|
659
863
|
}
|
|
660
864
|
return isResolvedLinearState(hydratedIssue.stateType, hydratedIssue.stateName);
|
|
@@ -662,19 +866,6 @@ function isTerminalDelegationState(existingIssue, hydratedIssue) {
|
|
|
662
866
|
function hasCompleteIssueContext(issue) {
|
|
663
867
|
return Boolean(issue.stateName && issue.delegateId && issue.teamId && issue.teamKey);
|
|
664
868
|
}
|
|
665
|
-
function mergePendingImplementationContext(existingJson, normalized) {
|
|
666
|
-
const existing = existingJson ? safeJsonParse(existingJson) ?? {} : {};
|
|
667
|
-
const next = { ...existing };
|
|
668
|
-
const promptContext = normalized.agentSession?.promptContext?.trim();
|
|
669
|
-
const promptBody = normalized.agentSession?.promptBody?.trim();
|
|
670
|
-
if (promptContext) {
|
|
671
|
-
next.promptContext = promptContext;
|
|
672
|
-
}
|
|
673
|
-
if (promptBody) {
|
|
674
|
-
next.promptBody = promptBody;
|
|
675
|
-
}
|
|
676
|
-
return Object.keys(next).length > 0 ? JSON.stringify(next) : undefined;
|
|
677
|
-
}
|
|
678
869
|
function mergeIssueMetadata(issue, liveIssue) {
|
|
679
870
|
return {
|
|
680
871
|
...issue,
|