@renseiai/agentfactory-nextjs 0.8.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.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/dist/src/__tests__/middleware-edge-safety.test.d.ts +2 -0
- package/dist/src/__tests__/middleware-edge-safety.test.d.ts.map +1 -0
- package/dist/src/__tests__/middleware-edge-safety.test.js +74 -0
- package/dist/src/__tests__/poll-project-filter.test.d.ts +2 -0
- package/dist/src/__tests__/poll-project-filter.test.d.ts.map +1 -0
- package/dist/src/__tests__/poll-project-filter.test.js +83 -0
- package/dist/src/__tests__/subpath-exports.test.d.ts +2 -0
- package/dist/src/__tests__/subpath-exports.test.d.ts.map +1 -0
- package/dist/src/__tests__/subpath-exports.test.js +35 -0
- package/dist/src/__tests__/webhook-project-filter.test.d.ts +2 -0
- package/dist/src/__tests__/webhook-project-filter.test.d.ts.map +1 -0
- package/dist/src/__tests__/webhook-project-filter.test.js +48 -0
- package/dist/src/factory.d.ts +140 -0
- package/dist/src/factory.d.ts.map +1 -0
- package/dist/src/factory.js +127 -0
- package/dist/src/handlers/cleanup.d.ts +44 -0
- package/dist/src/handlers/cleanup.d.ts.map +1 -0
- package/dist/src/handlers/cleanup.js +34 -0
- package/dist/src/handlers/config.d.ts +11 -0
- package/dist/src/handlers/config.d.ts.map +1 -0
- package/dist/src/handlers/config.js +20 -0
- package/dist/src/handlers/issue-tracker-proxy/index.d.ts +34 -0
- package/dist/src/handlers/issue-tracker-proxy/index.d.ts.map +1 -0
- package/dist/src/handlers/issue-tracker-proxy/index.js +230 -0
- package/dist/src/handlers/issue-tracker-proxy/serializer.d.ts +28 -0
- package/dist/src/handlers/issue-tracker-proxy/serializer.d.ts.map +1 -0
- package/dist/src/handlers/issue-tracker-proxy/serializer.js +95 -0
- package/dist/src/handlers/issue-tracker-proxy/types.d.ts +9 -0
- package/dist/src/handlers/issue-tracker-proxy/types.d.ts.map +1 -0
- package/dist/src/handlers/issue-tracker-proxy/types.js +4 -0
- package/dist/src/handlers/oauth/callback.d.ts +36 -0
- package/dist/src/handlers/oauth/callback.d.ts.map +1 -0
- package/dist/src/handlers/oauth/callback.js +96 -0
- package/dist/src/handlers/public/session-detail.d.ts +31 -0
- package/dist/src/handlers/public/session-detail.d.ts.map +1 -0
- package/dist/src/handlers/public/session-detail.js +91 -0
- package/dist/src/handlers/public/sessions-list.d.ts +22 -0
- package/dist/src/handlers/public/sessions-list.d.ts.map +1 -0
- package/dist/src/handlers/public/sessions-list.js +75 -0
- package/dist/src/handlers/public/stats.d.ts +28 -0
- package/dist/src/handlers/public/stats.d.ts.map +1 -0
- package/dist/src/handlers/public/stats.js +66 -0
- package/dist/src/handlers/sessions/activity.d.ts +15 -0
- package/dist/src/handlers/sessions/activity.d.ts.map +1 -0
- package/dist/src/handlers/sessions/activity.js +93 -0
- package/dist/src/handlers/sessions/claim.d.ts +15 -0
- package/dist/src/handlers/sessions/claim.d.ts.map +1 -0
- package/dist/src/handlers/sessions/claim.js +139 -0
- package/dist/src/handlers/sessions/completion.d.ts +16 -0
- package/dist/src/handlers/sessions/completion.d.ts.map +1 -0
- package/dist/src/handlers/sessions/completion.js +82 -0
- package/dist/src/handlers/sessions/external-urls.d.ts +15 -0
- package/dist/src/handlers/sessions/external-urls.d.ts.map +1 -0
- package/dist/src/handlers/sessions/external-urls.js +70 -0
- package/dist/src/handlers/sessions/get.d.ts +19 -0
- package/dist/src/handlers/sessions/get.d.ts.map +1 -0
- package/dist/src/handlers/sessions/get.js +47 -0
- package/dist/src/handlers/sessions/list.d.ts +27 -0
- package/dist/src/handlers/sessions/list.d.ts.map +1 -0
- package/dist/src/handlers/sessions/list.js +51 -0
- package/dist/src/handlers/sessions/lock-refresh.d.ts +14 -0
- package/dist/src/handlers/sessions/lock-refresh.d.ts.map +1 -0
- package/dist/src/handlers/sessions/lock-refresh.js +38 -0
- package/dist/src/handlers/sessions/progress.d.ts +15 -0
- package/dist/src/handlers/sessions/progress.d.ts.map +1 -0
- package/dist/src/handlers/sessions/progress.js +94 -0
- package/dist/src/handlers/sessions/prompts.d.ts +15 -0
- package/dist/src/handlers/sessions/prompts.d.ts.map +1 -0
- package/dist/src/handlers/sessions/prompts.js +91 -0
- package/dist/src/handlers/sessions/status.d.ts +19 -0
- package/dist/src/handlers/sessions/status.d.ts.map +1 -0
- package/dist/src/handlers/sessions/status.js +187 -0
- package/dist/src/handlers/sessions/tool-error.d.ts +15 -0
- package/dist/src/handlers/sessions/tool-error.d.ts.map +1 -0
- package/dist/src/handlers/sessions/tool-error.js +103 -0
- package/dist/src/handlers/sessions/transfer-ownership.d.ts +14 -0
- package/dist/src/handlers/sessions/transfer-ownership.d.ts.map +1 -0
- package/dist/src/handlers/sessions/transfer-ownership.js +56 -0
- package/dist/src/handlers/workers/get-delete.d.ts +15 -0
- package/dist/src/handlers/workers/get-delete.d.ts.map +1 -0
- package/dist/src/handlers/workers/get-delete.js +58 -0
- package/dist/src/handlers/workers/heartbeat.d.ts +14 -0
- package/dist/src/handlers/workers/heartbeat.d.ts.map +1 -0
- package/dist/src/handlers/workers/heartbeat.js +42 -0
- package/dist/src/handlers/workers/list.d.ts +22 -0
- package/dist/src/handlers/workers/list.d.ts.map +1 -0
- package/dist/src/handlers/workers/list.js +33 -0
- package/dist/src/handlers/workers/poll.d.ts +14 -0
- package/dist/src/handlers/workers/poll.d.ts.map +1 -0
- package/dist/src/handlers/workers/poll.js +96 -0
- package/dist/src/handlers/workers/register.d.ts +9 -0
- package/dist/src/handlers/workers/register.d.ts.map +1 -0
- package/dist/src/handlers/workers/register.js +45 -0
- package/dist/src/index.d.ts +52 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +56 -0
- package/dist/src/linear-client-resolver.d.ts +59 -0
- package/dist/src/linear-client-resolver.d.ts.map +1 -0
- package/dist/src/linear-client-resolver.js +104 -0
- package/dist/src/middleware/cron-auth.d.ts +21 -0
- package/dist/src/middleware/cron-auth.d.ts.map +1 -0
- package/dist/src/middleware/cron-auth.js +46 -0
- package/dist/src/middleware/factory.d.ts +33 -0
- package/dist/src/middleware/factory.d.ts.map +1 -0
- package/dist/src/middleware/factory.js +185 -0
- package/dist/src/middleware/index.d.ts +16 -0
- package/dist/src/middleware/index.d.ts.map +1 -0
- package/dist/src/middleware/index.js +14 -0
- package/dist/src/middleware/types.d.ts +35 -0
- package/dist/src/middleware/types.d.ts.map +1 -0
- package/dist/src/middleware/types.js +4 -0
- package/dist/src/middleware/worker-auth.d.ts +25 -0
- package/dist/src/middleware/worker-auth.d.ts.map +1 -0
- package/dist/src/middleware/worker-auth.js +43 -0
- package/dist/src/orchestrator/error-formatting.d.ts +8 -0
- package/dist/src/orchestrator/error-formatting.d.ts.map +1 -0
- package/dist/src/orchestrator/error-formatting.js +35 -0
- package/dist/src/orchestrator/index.d.ts +4 -0
- package/dist/src/orchestrator/index.d.ts.map +1 -0
- package/dist/src/orchestrator/index.js +2 -0
- package/dist/src/orchestrator/types.d.ts +53 -0
- package/dist/src/orchestrator/types.d.ts.map +1 -0
- package/dist/src/orchestrator/types.js +4 -0
- package/dist/src/orchestrator/webhook-orchestrator.d.ts +32 -0
- package/dist/src/orchestrator/webhook-orchestrator.d.ts.map +1 -0
- package/dist/src/orchestrator/webhook-orchestrator.js +373 -0
- package/dist/src/types.d.ts +101 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +7 -0
- package/dist/src/webhook/governor-bridge.d.ts +23 -0
- package/dist/src/webhook/governor-bridge.d.ts.map +1 -0
- package/dist/src/webhook/governor-bridge.js +36 -0
- package/dist/src/webhook/handlers/issue-updated.d.ts +15 -0
- package/dist/src/webhook/handlers/issue-updated.d.ts.map +1 -0
- package/dist/src/webhook/handlers/issue-updated.js +771 -0
- package/dist/src/webhook/handlers/session-created.d.ts +9 -0
- package/dist/src/webhook/handlers/session-created.d.ts.map +1 -0
- package/dist/src/webhook/handlers/session-created.js +337 -0
- package/dist/src/webhook/handlers/session-prompted.d.ts +9 -0
- package/dist/src/webhook/handlers/session-prompted.d.ts.map +1 -0
- package/dist/src/webhook/handlers/session-prompted.js +199 -0
- package/dist/src/webhook/handlers/session-updated.d.ts +9 -0
- package/dist/src/webhook/handlers/session-updated.d.ts.map +1 -0
- package/dist/src/webhook/handlers/session-updated.js +29 -0
- package/dist/src/webhook/processor.d.ts +22 -0
- package/dist/src/webhook/processor.d.ts.map +1 -0
- package/dist/src/webhook/processor.js +98 -0
- package/dist/src/webhook/signature.d.ts +16 -0
- package/dist/src/webhook/signature.d.ts.map +1 -0
- package/dist/src/webhook/signature.js +23 -0
- package/dist/src/webhook/utils.d.ts +61 -0
- package/dist/src/webhook/utils.d.ts.map +1 -0
- package/dist/src/webhook/utils.js +166 -0
- package/package.json +86 -0
|
@@ -0,0 +1,771 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handle Issue update events — status transition triggers.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Finished → auto-QA trigger (with circuit breaker)
|
|
6
|
+
* - → Rejected → escalation ladder (circuit breaker, decomposition, human escalation)
|
|
7
|
+
* - (Icebox|Rejected|Canceled) → Backlog → auto-development trigger (with circuit breaker)
|
|
8
|
+
* - Finished → Delivered → auto-acceptance trigger
|
|
9
|
+
*/
|
|
10
|
+
import { NextResponse } from 'next/server';
|
|
11
|
+
import { buildFailureContextBlock } from '@renseiai/agentfactory-linear';
|
|
12
|
+
import { checkIssueDeploymentStatus, formatFailedDeployments, eventTimestamp, } from '@renseiai/agentfactory';
|
|
13
|
+
import { publishGovernorEvent } from '../governor-bridge.js';
|
|
14
|
+
import { generateIdempotencyKey, isWebhookProcessed, storeSessionState, getSessionStateByIssue, dispatchWork, wasAgentWorked, didJustFailQA, getQAAttemptCount, recordQAAttempt, clearQAFailed, didJustQueueDevelopment, markDevelopmentQueued, didJustQueueAcceptance, markAcceptanceQueued, getWorkflowState, getTotalSessionCount, MAX_TOTAL_SESSIONS, didAcceptanceJustComplete, } from '@renseiai/agentfactory-server';
|
|
15
|
+
import { emitActivity, resolveStateName, isProjectAllowed, hasExcludedLabel, getAppUrl, } from '../utils.js';
|
|
16
|
+
export async function handleIssueUpdated(config, payload, log) {
|
|
17
|
+
const { data, updatedFrom, actor } = payload;
|
|
18
|
+
const issueId = data.id;
|
|
19
|
+
const issueIdentifier = data.identifier;
|
|
20
|
+
const currentStateName = data.state?.name;
|
|
21
|
+
const webhookId = payload.webhookId;
|
|
22
|
+
const issueLog = log.child({ issueId, issueIdentifier });
|
|
23
|
+
// Server-level project filter (applies to all paths)
|
|
24
|
+
const projectName = data.project?.name;
|
|
25
|
+
if (!isProjectAllowed(projectName, config.projects ?? [])) {
|
|
26
|
+
issueLog.debug('Project not handled by this server, skipping', { projectName });
|
|
27
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'project_not_allowed' });
|
|
28
|
+
}
|
|
29
|
+
const autoTrigger = config.autoTrigger;
|
|
30
|
+
// --- Governor event bridge (deferred until after auto-trigger processing) ---
|
|
31
|
+
// We capture the event here but don't publish until we know whether an auto-trigger
|
|
32
|
+
// dispatched work. If work was dispatched, the session is already stored in Redis
|
|
33
|
+
// and the governor will see it on its next poll — publishing would cause a duplicate.
|
|
34
|
+
let deferredGovernorEvent = null;
|
|
35
|
+
let workDispatched = false;
|
|
36
|
+
if (currentStateName && updatedFrom?.stateId) {
|
|
37
|
+
const governorIssue = {
|
|
38
|
+
id: issueId,
|
|
39
|
+
identifier: issueIdentifier,
|
|
40
|
+
title: data.title ?? '',
|
|
41
|
+
description: data.description ?? undefined,
|
|
42
|
+
status: currentStateName,
|
|
43
|
+
labels: (data.labels ?? []).map(l => l.name),
|
|
44
|
+
createdAt: data.createdAt ? new Date(data.createdAt).getTime() : Date.now(),
|
|
45
|
+
parentId: data.parent?.id,
|
|
46
|
+
project: projectName,
|
|
47
|
+
};
|
|
48
|
+
deferredGovernorEvent = {
|
|
49
|
+
type: 'issue-status-changed',
|
|
50
|
+
issueId,
|
|
51
|
+
issue: governorIssue,
|
|
52
|
+
previousStatus: updatedFrom.state?.name,
|
|
53
|
+
newStatus: currentStateName,
|
|
54
|
+
timestamp: eventTimestamp(),
|
|
55
|
+
source: 'webhook',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (config.governorMode === 'governor-only') {
|
|
59
|
+
if (deferredGovernorEvent) {
|
|
60
|
+
await publishGovernorEvent(deferredGovernorEvent);
|
|
61
|
+
}
|
|
62
|
+
issueLog.info('Governor-only mode: skipping direct dispatch', { currentStateName });
|
|
63
|
+
return NextResponse.json({ success: true, governorMode: 'governor-only' });
|
|
64
|
+
}
|
|
65
|
+
// === Handle Finished transition (auto-QA) ===
|
|
66
|
+
if (currentStateName === 'Finished' && updatedFrom?.stateId) {
|
|
67
|
+
issueLog.info('Issue transitioned to Finished', {
|
|
68
|
+
previousStateId: updatedFrom.stateId,
|
|
69
|
+
currentState: currentStateName,
|
|
70
|
+
actorName: actor?.name,
|
|
71
|
+
});
|
|
72
|
+
// Skip QA for sub-issues
|
|
73
|
+
let isChild = !!(data.parent);
|
|
74
|
+
if (!isChild) {
|
|
75
|
+
try {
|
|
76
|
+
const checkClient = await config.linearClient.getClient(payload.organizationId);
|
|
77
|
+
isChild = await checkClient.isChildIssue(issueId);
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
issueLog.warn('Failed to check if issue is a child', { error: err });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (isChild) {
|
|
84
|
+
issueLog.info('Sub-issue detected, skipping individual QA trigger');
|
|
85
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'sub_issue_skipped' });
|
|
86
|
+
}
|
|
87
|
+
if (!autoTrigger?.enableAutoQA) {
|
|
88
|
+
issueLog.debug('Auto-QA disabled, skipping QA trigger');
|
|
89
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'auto_qa_disabled' });
|
|
90
|
+
}
|
|
91
|
+
if (!isProjectAllowed(projectName, autoTrigger.autoQAProjects)) {
|
|
92
|
+
issueLog.debug('Project not in auto-QA list, skipping', { projectName });
|
|
93
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'project_not_allowed' });
|
|
94
|
+
}
|
|
95
|
+
const labels = data.labels;
|
|
96
|
+
if (hasExcludedLabel(labels, autoTrigger.autoQAExcludeLabels)) {
|
|
97
|
+
issueLog.debug('Issue has excluded label, skipping QA trigger');
|
|
98
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'excluded_label' });
|
|
99
|
+
}
|
|
100
|
+
if (autoTrigger.autoQARequireAgentWorked) {
|
|
101
|
+
const workRecord = await wasAgentWorked(issueId);
|
|
102
|
+
if (!workRecord) {
|
|
103
|
+
issueLog.debug('Issue not worked by agent, skipping QA trigger');
|
|
104
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'not_agent_worked' });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const justFailed = await didJustFailQA(issueId);
|
|
108
|
+
if (justFailed) {
|
|
109
|
+
issueLog.info('Issue in QA cooldown period, skipping');
|
|
110
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'qa_cooldown' });
|
|
111
|
+
}
|
|
112
|
+
// Check workflow state for circuit breaker before QA
|
|
113
|
+
try {
|
|
114
|
+
const workflowState = await getWorkflowState(issueId);
|
|
115
|
+
if (workflowState?.strategy === 'escalate-human') {
|
|
116
|
+
issueLog.warn('Circuit breaker: escalate-human strategy, blocking QA', {
|
|
117
|
+
cycleCount: workflowState.cycleCount,
|
|
118
|
+
strategy: workflowState.strategy,
|
|
119
|
+
});
|
|
120
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'circuit_breaker_escalate_human' });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
issueLog.warn('Failed to check workflow state for circuit breaker', { error: err });
|
|
125
|
+
}
|
|
126
|
+
const attemptCount = await getQAAttemptCount(issueId);
|
|
127
|
+
if (attemptCount >= 3) {
|
|
128
|
+
issueLog.warn('QA attempt limit reached', { attemptCount });
|
|
129
|
+
try {
|
|
130
|
+
const linearClient = await config.linearClient.getClient(payload.organizationId);
|
|
131
|
+
await linearClient.createComment(issueId, `## QA Limit Reached\n\nThis issue has failed automated QA ${attemptCount} times. Manual review is required.\n\nPlease review the previous QA failures and address the underlying issues before requesting another automated QA pass.`);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
issueLog.error('Failed to post QA limit comment', { error: err });
|
|
135
|
+
}
|
|
136
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'qa_limit_reached' });
|
|
137
|
+
}
|
|
138
|
+
const totalSessionsQA = await getTotalSessionCount(issueId);
|
|
139
|
+
if (totalSessionsQA >= MAX_TOTAL_SESSIONS) {
|
|
140
|
+
const agentClient = await config.linearClient.getClient(payload.organizationId);
|
|
141
|
+
await agentClient.createComment(issueId, `\u26a0\ufe0f **Session hard cap reached** (${totalSessionsQA}/${MAX_TOTAL_SESSIONS}). No more automated sessions will be created for this issue. Manual intervention required.`);
|
|
142
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'session_hard_cap' });
|
|
143
|
+
}
|
|
144
|
+
const idempotencyKey = generateIdempotencyKey(webhookId, `qa:${issueId}:${Date.now()}`);
|
|
145
|
+
if (await isWebhookProcessed(idempotencyKey)) {
|
|
146
|
+
issueLog.info('Duplicate QA trigger ignored', { idempotencyKey });
|
|
147
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'duplicate_qa_trigger' });
|
|
148
|
+
}
|
|
149
|
+
// Check deployment status before QA
|
|
150
|
+
try {
|
|
151
|
+
const deploymentResult = await checkIssueDeploymentStatus(issueIdentifier);
|
|
152
|
+
if (deploymentResult?.anyFailed) {
|
|
153
|
+
issueLog.info('Deployment failed, blocking QA', {
|
|
154
|
+
commitSha: deploymentResult.commitSha,
|
|
155
|
+
});
|
|
156
|
+
const linearClient = await config.linearClient.getClient(payload.organizationId);
|
|
157
|
+
await linearClient.createComment(issueId, `## QA Blocked: Deployment Failed\n\n` +
|
|
158
|
+
`Cannot proceed with QA until Vercel deployment succeeds.\n\n` +
|
|
159
|
+
formatFailedDeployments(deploymentResult) +
|
|
160
|
+
`\n\n**PR:** ${deploymentResult.pr.url}\n` +
|
|
161
|
+
`**Commit:** \`${deploymentResult.commitSha.slice(0, 7)}\`\n\n` +
|
|
162
|
+
`Please fix the deployment issues and move the issue back to Finished to retry QA.`);
|
|
163
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'deployment_failed' });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
issueLog.warn('Deployment check failed, proceeding with QA', { error: err });
|
|
168
|
+
}
|
|
169
|
+
await clearQAFailed(issueId);
|
|
170
|
+
const linearClient = await config.linearClient.getClient(payload.organizationId);
|
|
171
|
+
// Detect parent issues → qa-coordination
|
|
172
|
+
let qaWorkType = 'qa';
|
|
173
|
+
let qaPrompt = `QA ${issueIdentifier}`;
|
|
174
|
+
try {
|
|
175
|
+
const isParent = await linearClient.isParentIssue(issueId);
|
|
176
|
+
if (isParent) {
|
|
177
|
+
qaWorkType = 'qa-coordination';
|
|
178
|
+
qaPrompt = config.generatePrompt(issueIdentifier, 'qa-coordination');
|
|
179
|
+
issueLog.info('Parent issue detected, using qa-coordination work type');
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
issueLog.warn('Failed to detect parent issue for QA routing', { error: err });
|
|
184
|
+
}
|
|
185
|
+
// Enrich QA prompt with previous failure context
|
|
186
|
+
if (attemptCount > 0) {
|
|
187
|
+
try {
|
|
188
|
+
const workflowState = await getWorkflowState(issueId);
|
|
189
|
+
if (workflowState && workflowState.failureSummary) {
|
|
190
|
+
const wfContext = {
|
|
191
|
+
cycleCount: workflowState.cycleCount,
|
|
192
|
+
strategy: workflowState.strategy,
|
|
193
|
+
failureSummary: workflowState.failureSummary,
|
|
194
|
+
qaAttemptCount: attemptCount,
|
|
195
|
+
};
|
|
196
|
+
const contextBlock = buildFailureContextBlock(qaWorkType, wfContext);
|
|
197
|
+
if (contextBlock) {
|
|
198
|
+
qaPrompt += contextBlock;
|
|
199
|
+
issueLog.info('QA prompt enriched with failure context', {
|
|
200
|
+
cycleCount: workflowState.cycleCount,
|
|
201
|
+
attemptCount,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
issueLog.warn('Failed to enrich QA prompt with failure context', { error: err });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Create Linear AgentSession for QA
|
|
211
|
+
let qaSessionId;
|
|
212
|
+
try {
|
|
213
|
+
const appUrl = getAppUrl(config);
|
|
214
|
+
const sessionResult = await linearClient.createAgentSessionOnIssue({
|
|
215
|
+
issueId,
|
|
216
|
+
externalUrls: [{ label: 'Agent Dashboard', url: `${appUrl}/sessions/pending` }],
|
|
217
|
+
});
|
|
218
|
+
if (!sessionResult.success || !sessionResult.sessionId) {
|
|
219
|
+
issueLog.error('Failed to create Linear AgentSession for QA', { sessionResult });
|
|
220
|
+
return NextResponse.json({ success: false, error: 'Failed to create agent session for QA' });
|
|
221
|
+
}
|
|
222
|
+
qaSessionId = sessionResult.sessionId;
|
|
223
|
+
issueLog.info('Linear AgentSession created for QA', { sessionId: qaSessionId });
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
issueLog.error('Error creating Linear AgentSession for QA', { error: err });
|
|
227
|
+
return NextResponse.json({ success: false, error: 'Error creating agent session for QA' });
|
|
228
|
+
}
|
|
229
|
+
// Store session state IMMEDIATELY after creation so that the session-created
|
|
230
|
+
// webhook handler finds it and skips (prevents duplicate dispatch).
|
|
231
|
+
await storeSessionState(qaSessionId, {
|
|
232
|
+
issueId,
|
|
233
|
+
issueIdentifier,
|
|
234
|
+
providerSessionId: null,
|
|
235
|
+
worktreePath: '',
|
|
236
|
+
status: 'pending',
|
|
237
|
+
queuedAt: Date.now(),
|
|
238
|
+
promptContext: qaPrompt,
|
|
239
|
+
priority: 2,
|
|
240
|
+
organizationId: payload.organizationId,
|
|
241
|
+
workType: qaWorkType,
|
|
242
|
+
projectName,
|
|
243
|
+
});
|
|
244
|
+
await recordQAAttempt(issueId, qaSessionId);
|
|
245
|
+
const qaWork = {
|
|
246
|
+
sessionId: qaSessionId,
|
|
247
|
+
issueId,
|
|
248
|
+
issueIdentifier,
|
|
249
|
+
priority: 2,
|
|
250
|
+
queuedAt: Date.now(),
|
|
251
|
+
prompt: qaPrompt,
|
|
252
|
+
workType: qaWorkType,
|
|
253
|
+
projectName,
|
|
254
|
+
};
|
|
255
|
+
const qaResult = await dispatchWork(qaWork);
|
|
256
|
+
if (qaResult.dispatched || qaResult.parked) {
|
|
257
|
+
workDispatched = true;
|
|
258
|
+
issueLog.info('QA work dispatched', {
|
|
259
|
+
sessionId: qaSessionId,
|
|
260
|
+
attemptNumber: attemptCount + 1,
|
|
261
|
+
});
|
|
262
|
+
try {
|
|
263
|
+
const appUrl = getAppUrl(config);
|
|
264
|
+
await linearClient.updateAgentSession({
|
|
265
|
+
sessionId: qaSessionId,
|
|
266
|
+
externalUrls: [{ label: 'Agent Dashboard', url: `${appUrl}/sessions/${qaSessionId}` }],
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
issueLog.warn('Failed to update QA session externalUrl', { error: err });
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
await emitActivity(linearClient, qaSessionId, 'thought', `QA work queued (attempt #${attemptCount + 1}). Waiting for an available worker...`);
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
issueLog.warn('Failed to emit QA queued activity', { error: err });
|
|
277
|
+
}
|
|
278
|
+
try {
|
|
279
|
+
await linearClient.createComment(issueId, `## Automated QA Started\n\nQA attempt #${attemptCount + 1} has been queued.\n\nThe QA agent will:\n1. Checkout the PR branch\n2. Run tests and validation\n3. Verify implementation against requirements\n4. Promote to Delivered (pass) or return to Backlog (fail)`);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
issueLog.error('Failed to post QA start comment', { error: err });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
issueLog.error('Failed to queue QA work');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// === Handle → Rejected transition (escalation ladder) ===
|
|
290
|
+
// When QA/acceptance fails, the orchestrator transitions the issue to Rejected.
|
|
291
|
+
// Check the escalation strategy and act accordingly.
|
|
292
|
+
if (currentStateName === 'Rejected' && updatedFrom?.stateId) {
|
|
293
|
+
try {
|
|
294
|
+
const workflowState = await getWorkflowState(issueId);
|
|
295
|
+
if (workflowState) {
|
|
296
|
+
const { strategy, cycleCount, failureSummary } = workflowState;
|
|
297
|
+
if (strategy === 'escalate-human') {
|
|
298
|
+
issueLog.warn('Escalation ladder: escalate-human — creating blocker and stopping loop', {
|
|
299
|
+
cycleCount,
|
|
300
|
+
strategy,
|
|
301
|
+
});
|
|
302
|
+
const linearClient = await config.linearClient.getClient(payload.organizationId);
|
|
303
|
+
// Post escalation summary comment
|
|
304
|
+
const totalCostLine = workflowState.phases
|
|
305
|
+
? (() => {
|
|
306
|
+
const allPhases = [
|
|
307
|
+
...workflowState.phases.development,
|
|
308
|
+
...workflowState.phases.qa,
|
|
309
|
+
...workflowState.phases.refinement,
|
|
310
|
+
...workflowState.phases.acceptance,
|
|
311
|
+
];
|
|
312
|
+
const totalCost = allPhases.reduce((sum, p) => sum + (p.costUsd ?? 0), 0);
|
|
313
|
+
return totalCost > 0 ? `\n**Total cost across all attempts:** $${totalCost.toFixed(2)}` : '';
|
|
314
|
+
})()
|
|
315
|
+
: '';
|
|
316
|
+
try {
|
|
317
|
+
await linearClient.createComment(issueId, `## Circuit Breaker: Human Intervention Required\n\n` +
|
|
318
|
+
`This issue has gone through **${cycleCount} dev-QA-rejected cycles** without passing.\n` +
|
|
319
|
+
`The automated system is stopping further attempts.\n` +
|
|
320
|
+
totalCostLine +
|
|
321
|
+
`\n\n### Failure History\n\n${failureSummary ?? 'No failure details recorded.'}\n\n` +
|
|
322
|
+
`### Recommended Actions\n` +
|
|
323
|
+
`1. Review the failure patterns above\n` +
|
|
324
|
+
`2. Consider if the acceptance criteria need clarification\n` +
|
|
325
|
+
`3. Investigate whether there's an architectural issue\n` +
|
|
326
|
+
`4. Manually fix or decompose the issue before re-enabling automation`);
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
issueLog.error('Failed to post escalation comment', { error: err });
|
|
330
|
+
}
|
|
331
|
+
// Create a blocker issue in Icebox with 'Needs Human' label
|
|
332
|
+
try {
|
|
333
|
+
const issue = await linearClient.getIssue(issueId);
|
|
334
|
+
const team = await issue.team;
|
|
335
|
+
if (team) {
|
|
336
|
+
const statuses = await linearClient.getTeamStatuses(team.id);
|
|
337
|
+
const iceboxStateId = statuses['Icebox'];
|
|
338
|
+
// Find 'Needs Human' label
|
|
339
|
+
const allLabels = await linearClient.linearClient.issueLabels();
|
|
340
|
+
const needsHumanLabel = allLabels.nodes.find((l) => l.name.toLowerCase() === 'needs human');
|
|
341
|
+
const blockerTitle = `Human review needed: ${issueIdentifier} failed ${cycleCount} automated cycles`;
|
|
342
|
+
const blockerDescription = [
|
|
343
|
+
`This issue has failed **${cycleCount} automated dev-QA-rejected cycles** and requires human intervention.`,
|
|
344
|
+
'',
|
|
345
|
+
'### Failure History',
|
|
346
|
+
failureSummary ?? 'No failure details recorded.',
|
|
347
|
+
'',
|
|
348
|
+
'---',
|
|
349
|
+
`*Source issue: ${issueIdentifier}*`,
|
|
350
|
+
].join('\n');
|
|
351
|
+
const createPayload = {
|
|
352
|
+
title: blockerTitle,
|
|
353
|
+
description: blockerDescription,
|
|
354
|
+
teamId: team.id,
|
|
355
|
+
...(iceboxStateId && { stateId: iceboxStateId }),
|
|
356
|
+
...(needsHumanLabel && { labelIds: [needsHumanLabel.id] }),
|
|
357
|
+
};
|
|
358
|
+
// Add project if available
|
|
359
|
+
const project = await issue.project;
|
|
360
|
+
if (project) {
|
|
361
|
+
createPayload.projectId = project.id;
|
|
362
|
+
}
|
|
363
|
+
const blockerIssue = await linearClient.createIssue(createPayload);
|
|
364
|
+
// Create blocking relation: blocker blocks source issue
|
|
365
|
+
await linearClient.createIssueRelation({
|
|
366
|
+
issueId: blockerIssue.id,
|
|
367
|
+
relatedIssueId: issueId,
|
|
368
|
+
type: 'blocks',
|
|
369
|
+
});
|
|
370
|
+
issueLog.info('Escalation ladder: blocker issue created', {
|
|
371
|
+
issueId,
|
|
372
|
+
blockerIssueId: blockerIssue.id,
|
|
373
|
+
blockerIdentifier: blockerIssue.identifier,
|
|
374
|
+
cycleCount,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
issueLog.warn('Escalation ladder: could not resolve team for blocker creation', { issueId });
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
issueLog.error('Failed to create blocker issue for escalation', { error: err });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else if (strategy === 'decompose') {
|
|
386
|
+
issueLog.info('Escalation ladder: decompose strategy — refinement will attempt decomposition', {
|
|
387
|
+
cycleCount,
|
|
388
|
+
strategy,
|
|
389
|
+
});
|
|
390
|
+
// The decomposition strategy will be handled via prompt enrichment (SUP-713)
|
|
391
|
+
// by injecting decomposition instructions into the refinement prompt
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
issueLog.warn('Failed to check workflow state for escalation ladder', { error: err });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
// === Handle → Backlog transition (auto-development) ===
|
|
400
|
+
// Triggers from: Icebox → Backlog (new issues), Rejected → Backlog (post-refinement retries), etc.
|
|
401
|
+
if (currentStateName === 'Backlog' && updatedFrom?.stateId) {
|
|
402
|
+
const previousStateName = await resolveStateName(config, payload.organizationId, issueId, updatedFrom.stateId);
|
|
403
|
+
// Skip transitions from states that don't indicate readiness for development
|
|
404
|
+
// (e.g., Backlog → Backlog is a no-op, Started → Backlog means work was abandoned)
|
|
405
|
+
// Finished → Backlog is a QA failure (QA fail status is now Backlog instead of Rejected)
|
|
406
|
+
const allowedPreviousStates = ['Icebox', 'Rejected', 'Canceled', 'Finished'];
|
|
407
|
+
if (!allowedPreviousStates.includes(previousStateName ?? '')) {
|
|
408
|
+
issueLog.debug('Issue transitioned to Backlog from non-triggering state', { previousStateName });
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
const isRetry = previousStateName === 'Rejected' || previousStateName === 'Finished';
|
|
412
|
+
issueLog.info('Issue transitioned to Backlog', {
|
|
413
|
+
previousStateName,
|
|
414
|
+
isRetry,
|
|
415
|
+
actorName: actor?.name,
|
|
416
|
+
});
|
|
417
|
+
// Skip development for sub-issues — coordinator manages sub-issue lifecycle via parent
|
|
418
|
+
let isChildForDev = !!(data.parent);
|
|
419
|
+
if (!isChildForDev) {
|
|
420
|
+
try {
|
|
421
|
+
const checkClient = await config.linearClient.getClient(payload.organizationId);
|
|
422
|
+
isChildForDev = await checkClient.isChildIssue(issueId);
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
issueLog.warn('Failed to check if issue is a child', { error: err });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
if (isChildForDev) {
|
|
429
|
+
issueLog.info('Sub-issue detected, skipping individual development trigger');
|
|
430
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'sub_issue_skipped' });
|
|
431
|
+
}
|
|
432
|
+
// Circuit breaker: check workflow state for escalate-human strategy
|
|
433
|
+
if (isRetry) {
|
|
434
|
+
try {
|
|
435
|
+
const workflowState = await getWorkflowState(issueId);
|
|
436
|
+
if (workflowState && workflowState.strategy === 'escalate-human') {
|
|
437
|
+
issueLog.warn('Circuit breaker: issue at escalate-human strategy, skipping auto-development', {
|
|
438
|
+
cycleCount: workflowState.cycleCount,
|
|
439
|
+
strategy: workflowState.strategy,
|
|
440
|
+
});
|
|
441
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'circuit_breaker_escalate_human' });
|
|
442
|
+
}
|
|
443
|
+
if (workflowState) {
|
|
444
|
+
issueLog.info('Workflow state found for retry', {
|
|
445
|
+
cycleCount: workflowState.cycleCount,
|
|
446
|
+
strategy: workflowState.strategy,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
catch (err) {
|
|
451
|
+
issueLog.warn('Failed to check workflow state for circuit breaker', { error: err });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const totalSessionsDev = await getTotalSessionCount(issueId);
|
|
455
|
+
if (totalSessionsDev >= MAX_TOTAL_SESSIONS) {
|
|
456
|
+
const agentClient = await config.linearClient.getClient(payload.organizationId);
|
|
457
|
+
await agentClient.createComment(issueId, `\u26a0\ufe0f **Session hard cap reached** (${totalSessionsDev}/${MAX_TOTAL_SESSIONS}). No more automated sessions will be created for this issue. Manual intervention required.`);
|
|
458
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'session_hard_cap' });
|
|
459
|
+
}
|
|
460
|
+
const existingSession = await getSessionStateByIssue(issueId);
|
|
461
|
+
if (existingSession && ['running', 'claimed', 'pending'].includes(existingSession.status)) {
|
|
462
|
+
issueLog.info('Session already active, skipping development trigger');
|
|
463
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'session_already_active' });
|
|
464
|
+
}
|
|
465
|
+
if (await didJustQueueDevelopment(issueId)) {
|
|
466
|
+
issueLog.info('Issue in development cooldown period, skipping');
|
|
467
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'development_cooldown' });
|
|
468
|
+
}
|
|
469
|
+
const idempotencyKey = generateIdempotencyKey(webhookId, `dev:${issueId}:${Date.now()}`);
|
|
470
|
+
if (await isWebhookProcessed(idempotencyKey)) {
|
|
471
|
+
issueLog.info('Duplicate development trigger ignored', { idempotencyKey });
|
|
472
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'duplicate_dev_trigger' });
|
|
473
|
+
}
|
|
474
|
+
const linearClient = await config.linearClient.getClient(payload.organizationId);
|
|
475
|
+
await markDevelopmentQueued(issueId);
|
|
476
|
+
// Auto-detect parent for coordination (before session creation so we can store immediately)
|
|
477
|
+
let workType = 'development';
|
|
478
|
+
try {
|
|
479
|
+
const isParent = await linearClient.isParentIssue(issueId);
|
|
480
|
+
if (isParent) {
|
|
481
|
+
workType = 'coordination';
|
|
482
|
+
issueLog.info('Parent issue detected, switching to coordination work type');
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
catch (err) {
|
|
486
|
+
issueLog.warn('Failed to check if issue is parent', { error: err });
|
|
487
|
+
}
|
|
488
|
+
let prompt = config.generatePrompt(issueIdentifier, workType);
|
|
489
|
+
// Enrich prompt with failure context for retries
|
|
490
|
+
if (isRetry) {
|
|
491
|
+
try {
|
|
492
|
+
const workflowState = await getWorkflowState(issueId);
|
|
493
|
+
if (workflowState && workflowState.cycleCount > 0) {
|
|
494
|
+
const wfContext = {
|
|
495
|
+
cycleCount: workflowState.cycleCount,
|
|
496
|
+
strategy: workflowState.strategy,
|
|
497
|
+
failureSummary: workflowState.failureSummary,
|
|
498
|
+
};
|
|
499
|
+
const contextBlock = buildFailureContextBlock(workType, wfContext);
|
|
500
|
+
if (contextBlock) {
|
|
501
|
+
prompt += contextBlock;
|
|
502
|
+
issueLog.info('Development prompt enriched with failure context', {
|
|
503
|
+
cycleCount: workflowState.cycleCount,
|
|
504
|
+
strategy: workflowState.strategy,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch (err) {
|
|
510
|
+
issueLog.warn('Failed to enrich development prompt with failure context', { error: err });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
let devSessionId;
|
|
514
|
+
try {
|
|
515
|
+
const appUrl = getAppUrl(config);
|
|
516
|
+
const sessionResult = await linearClient.createAgentSessionOnIssue({
|
|
517
|
+
issueId,
|
|
518
|
+
externalUrls: [{ label: 'Agent Dashboard', url: `${appUrl}/sessions/pending` }],
|
|
519
|
+
});
|
|
520
|
+
if (!sessionResult.success || !sessionResult.sessionId) {
|
|
521
|
+
issueLog.error('Failed to create Linear AgentSession', { sessionResult });
|
|
522
|
+
return NextResponse.json({ success: false, error: 'Failed to create agent session' });
|
|
523
|
+
}
|
|
524
|
+
devSessionId = sessionResult.sessionId;
|
|
525
|
+
issueLog.info('Linear AgentSession created', { sessionId: devSessionId });
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
issueLog.error('Error creating Linear AgentSession', { error: err });
|
|
529
|
+
return NextResponse.json({ success: false, error: 'Error creating agent session' });
|
|
530
|
+
}
|
|
531
|
+
// Store session state IMMEDIATELY after creation so that the session-created
|
|
532
|
+
// webhook handler finds it and skips (prevents duplicate dispatch).
|
|
533
|
+
await storeSessionState(devSessionId, {
|
|
534
|
+
issueId,
|
|
535
|
+
issueIdentifier,
|
|
536
|
+
providerSessionId: null,
|
|
537
|
+
worktreePath: '',
|
|
538
|
+
status: 'pending',
|
|
539
|
+
queuedAt: Date.now(),
|
|
540
|
+
promptContext: prompt,
|
|
541
|
+
priority: 3,
|
|
542
|
+
organizationId: payload.organizationId,
|
|
543
|
+
workType,
|
|
544
|
+
projectName,
|
|
545
|
+
});
|
|
546
|
+
const devWork = {
|
|
547
|
+
sessionId: devSessionId,
|
|
548
|
+
issueId,
|
|
549
|
+
issueIdentifier,
|
|
550
|
+
priority: 3,
|
|
551
|
+
queuedAt: Date.now(),
|
|
552
|
+
prompt,
|
|
553
|
+
workType,
|
|
554
|
+
projectName,
|
|
555
|
+
};
|
|
556
|
+
const devResult = await dispatchWork(devWork);
|
|
557
|
+
if (devResult.dispatched || devResult.parked) {
|
|
558
|
+
workDispatched = true;
|
|
559
|
+
const retryLabel = isRetry ? ' (retry)' : '';
|
|
560
|
+
issueLog.info(`Development work dispatched${retryLabel}`, { sessionId: devSessionId });
|
|
561
|
+
try {
|
|
562
|
+
const appUrl = getAppUrl(config);
|
|
563
|
+
await linearClient.updateAgentSession({
|
|
564
|
+
sessionId: devSessionId,
|
|
565
|
+
externalUrls: [{ label: 'Agent Dashboard', url: `${appUrl}/sessions/${devSessionId}` }],
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
catch (err) {
|
|
569
|
+
issueLog.warn('Failed to update session externalUrl', { error: err });
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
const activityMsg = isRetry
|
|
573
|
+
? 'Development work queued (retry after refinement). Waiting for an available worker...'
|
|
574
|
+
: 'Development work queued. Waiting for an available worker...';
|
|
575
|
+
await emitActivity(linearClient, devSessionId, 'thought', activityMsg);
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
issueLog.warn('Failed to emit queued activity', { error: err });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
issueLog.error('Failed to queue development work');
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
// === Handle Finished → Delivered transition (auto-acceptance) ===
|
|
587
|
+
if (currentStateName === 'Delivered' && updatedFrom?.stateId) {
|
|
588
|
+
const previousStateName = await resolveStateName(config, payload.organizationId, issueId, updatedFrom.stateId);
|
|
589
|
+
if (previousStateName !== 'Finished') {
|
|
590
|
+
issueLog.debug('Issue transitioned to Delivered but not from Finished', { previousStateName });
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
issueLog.info('Issue transitioned from Finished to Delivered', {
|
|
594
|
+
previousStateName,
|
|
595
|
+
actorName: actor?.name,
|
|
596
|
+
});
|
|
597
|
+
// Skip acceptance for sub-issues
|
|
598
|
+
let isChildForAcceptance = !!(data.parent);
|
|
599
|
+
if (!isChildForAcceptance) {
|
|
600
|
+
try {
|
|
601
|
+
const checkClient = await config.linearClient.getClient(payload.organizationId);
|
|
602
|
+
isChildForAcceptance = await checkClient.isChildIssue(issueId);
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
issueLog.warn('Failed to check if issue is a child', { error: err });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (isChildForAcceptance) {
|
|
609
|
+
issueLog.info('Sub-issue detected, skipping individual acceptance trigger');
|
|
610
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'sub_issue_skipped' });
|
|
611
|
+
}
|
|
612
|
+
if (!autoTrigger?.enableAutoAcceptance) {
|
|
613
|
+
issueLog.debug('Auto-acceptance disabled, skipping acceptance trigger');
|
|
614
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'auto_acceptance_disabled' });
|
|
615
|
+
}
|
|
616
|
+
if (!isProjectAllowed(projectName, autoTrigger.autoAcceptanceProjects)) {
|
|
617
|
+
issueLog.debug('Project not in auto-acceptance list, skipping', { projectName });
|
|
618
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'project_not_allowed' });
|
|
619
|
+
}
|
|
620
|
+
const labels = data.labels;
|
|
621
|
+
if (hasExcludedLabel(labels, autoTrigger.autoAcceptanceExcludeLabels)) {
|
|
622
|
+
issueLog.debug('Issue has excluded label, skipping acceptance trigger');
|
|
623
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'excluded_label' });
|
|
624
|
+
}
|
|
625
|
+
if (autoTrigger.autoAcceptanceRequireAgentWorked) {
|
|
626
|
+
const workRecord = await wasAgentWorked(issueId);
|
|
627
|
+
if (!workRecord) {
|
|
628
|
+
issueLog.debug('Issue not worked by agent, skipping acceptance trigger');
|
|
629
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'not_agent_worked' });
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (await didJustQueueAcceptance(issueId)) {
|
|
633
|
+
issueLog.info('Issue in acceptance cooldown period, skipping');
|
|
634
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'acceptance_cooldown' });
|
|
635
|
+
}
|
|
636
|
+
if (await didAcceptanceJustComplete(issueId)) {
|
|
637
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'acceptance_recently_completed' });
|
|
638
|
+
}
|
|
639
|
+
const totalSessionsAcc = await getTotalSessionCount(issueId);
|
|
640
|
+
if (totalSessionsAcc >= MAX_TOTAL_SESSIONS) {
|
|
641
|
+
const agentClient = await config.linearClient.getClient(payload.organizationId);
|
|
642
|
+
await agentClient.createComment(issueId, `\u26a0\ufe0f **Session hard cap reached** (${totalSessionsAcc}/${MAX_TOTAL_SESSIONS}). No more automated sessions will be created for this issue. Manual intervention required.`);
|
|
643
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'session_hard_cap' });
|
|
644
|
+
}
|
|
645
|
+
const idempotencyKey = generateIdempotencyKey(webhookId, `acceptance:${issueId}:${Date.now()}`);
|
|
646
|
+
if (await isWebhookProcessed(idempotencyKey)) {
|
|
647
|
+
issueLog.info('Duplicate acceptance trigger ignored', { idempotencyKey });
|
|
648
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'duplicate_acceptance_trigger' });
|
|
649
|
+
}
|
|
650
|
+
// Check deployment status
|
|
651
|
+
try {
|
|
652
|
+
const deploymentResult = await checkIssueDeploymentStatus(issueIdentifier);
|
|
653
|
+
if (deploymentResult?.anyFailed) {
|
|
654
|
+
issueLog.info('Deployment failed, blocking acceptance');
|
|
655
|
+
const linearClient = await config.linearClient.getClient(payload.organizationId);
|
|
656
|
+
await linearClient.createComment(issueId, `## Acceptance Blocked: Deployment Failed\n\n` +
|
|
657
|
+
`Cannot proceed with acceptance testing until Vercel deployment succeeds.\n\n` +
|
|
658
|
+
formatFailedDeployments(deploymentResult) +
|
|
659
|
+
`\n\n**PR:** ${deploymentResult.pr.url}\n` +
|
|
660
|
+
`**Commit:** \`${deploymentResult.commitSha.slice(0, 7)}\`\n\n` +
|
|
661
|
+
`Please fix the deployment issues. The issue will remain in Delivered status.`);
|
|
662
|
+
return NextResponse.json({ success: true, skipped: true, reason: 'deployment_failed' });
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
catch (err) {
|
|
666
|
+
issueLog.warn('Deployment check failed, proceeding with acceptance', { error: err });
|
|
667
|
+
}
|
|
668
|
+
await markAcceptanceQueued(issueId);
|
|
669
|
+
const linearClient = await config.linearClient.getClient(payload.organizationId);
|
|
670
|
+
// Detect parent → acceptance-coordination (only if sub-issues were actually worked)
|
|
671
|
+
let acceptanceWorkType = 'acceptance';
|
|
672
|
+
let acceptancePrompt = config.generatePrompt(issueIdentifier, 'acceptance');
|
|
673
|
+
try {
|
|
674
|
+
const isParent = await linearClient.isParentIssue(issueId);
|
|
675
|
+
if (isParent) {
|
|
676
|
+
const hasWorked = await linearClient.hasWorkedSubIssues(issueId);
|
|
677
|
+
if (hasWorked) {
|
|
678
|
+
acceptanceWorkType = 'acceptance-coordination';
|
|
679
|
+
acceptancePrompt = config.generatePrompt(issueIdentifier, 'acceptance-coordination');
|
|
680
|
+
issueLog.info('Parent issue with worked sub-issues detected, using acceptance-coordination work type');
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
issueLog.info('Parent issue with no worked sub-issues, using regular acceptance work type');
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
issueLog.warn('Failed to detect parent issue for acceptance routing', { error: err });
|
|
689
|
+
}
|
|
690
|
+
// Create Linear AgentSession for acceptance
|
|
691
|
+
let acceptanceSessionId;
|
|
692
|
+
try {
|
|
693
|
+
const appUrl = getAppUrl(config);
|
|
694
|
+
const sessionResult = await linearClient.createAgentSessionOnIssue({
|
|
695
|
+
issueId,
|
|
696
|
+
externalUrls: [{ label: 'Agent Dashboard', url: `${appUrl}/sessions/pending` }],
|
|
697
|
+
});
|
|
698
|
+
if (!sessionResult.success || !sessionResult.sessionId) {
|
|
699
|
+
issueLog.error('Failed to create Linear AgentSession for acceptance', { sessionResult });
|
|
700
|
+
return NextResponse.json({ success: false, error: 'Failed to create agent session for acceptance' });
|
|
701
|
+
}
|
|
702
|
+
acceptanceSessionId = sessionResult.sessionId;
|
|
703
|
+
issueLog.info('Linear AgentSession created for acceptance', { sessionId: acceptanceSessionId });
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
issueLog.error('Error creating Linear AgentSession for acceptance', { error: err });
|
|
707
|
+
return NextResponse.json({ success: false, error: 'Error creating agent session for acceptance' });
|
|
708
|
+
}
|
|
709
|
+
await storeSessionState(acceptanceSessionId, {
|
|
710
|
+
issueId,
|
|
711
|
+
issueIdentifier,
|
|
712
|
+
providerSessionId: null,
|
|
713
|
+
worktreePath: '',
|
|
714
|
+
status: 'pending',
|
|
715
|
+
queuedAt: Date.now(),
|
|
716
|
+
promptContext: acceptancePrompt,
|
|
717
|
+
priority: 2,
|
|
718
|
+
organizationId: payload.organizationId,
|
|
719
|
+
workType: acceptanceWorkType,
|
|
720
|
+
projectName,
|
|
721
|
+
});
|
|
722
|
+
const acceptanceWork = {
|
|
723
|
+
sessionId: acceptanceSessionId,
|
|
724
|
+
issueId,
|
|
725
|
+
issueIdentifier,
|
|
726
|
+
priority: 2,
|
|
727
|
+
queuedAt: Date.now(),
|
|
728
|
+
prompt: acceptancePrompt,
|
|
729
|
+
workType: acceptanceWorkType,
|
|
730
|
+
projectName,
|
|
731
|
+
};
|
|
732
|
+
const accResult = await dispatchWork(acceptanceWork);
|
|
733
|
+
if (accResult.dispatched || accResult.parked) {
|
|
734
|
+
workDispatched = true;
|
|
735
|
+
issueLog.info('Acceptance work dispatched', { sessionId: acceptanceSessionId });
|
|
736
|
+
try {
|
|
737
|
+
const appUrl = getAppUrl(config);
|
|
738
|
+
await linearClient.updateAgentSession({
|
|
739
|
+
sessionId: acceptanceSessionId,
|
|
740
|
+
externalUrls: [{ label: 'Agent Dashboard', url: `${appUrl}/sessions/${acceptanceSessionId}` }],
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
catch (err) {
|
|
744
|
+
issueLog.warn('Failed to update acceptance session externalUrl', { error: err });
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
await emitActivity(linearClient, acceptanceSessionId, 'thought', 'Acceptance work queued. Waiting for an available worker...');
|
|
748
|
+
}
|
|
749
|
+
catch (err) {
|
|
750
|
+
issueLog.warn('Failed to emit acceptance queued activity', { error: err });
|
|
751
|
+
}
|
|
752
|
+
try {
|
|
753
|
+
await linearClient.createComment(issueId, `## Acceptance Processing Started\n\nQA passed. Validating work completion and preparing to merge PR...\n\nThe acceptance handler will:\n1. Verify the preview deployment is working\n2. Check PR is ready to merge (CI passing, no conflicts)\n3. Merge the PR\n4. Clean up local resources\n5. Move issue to Accepted on success`);
|
|
754
|
+
}
|
|
755
|
+
catch (err) {
|
|
756
|
+
issueLog.error('Failed to post acceptance start comment', { error: err });
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
issueLog.error('Failed to queue acceptance work');
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
// Publish deferred governor event only if no auto-trigger dispatched work.
|
|
765
|
+
// When work is dispatched, the session is already stored in Redis, so the
|
|
766
|
+
// governor will see it and skip — no need to also send an event.
|
|
767
|
+
if (deferredGovernorEvent && !workDispatched) {
|
|
768
|
+
await publishGovernorEvent(deferredGovernorEvent);
|
|
769
|
+
}
|
|
770
|
+
return null; // Continue processing
|
|
771
|
+
}
|