claude-teammate 0.1.306 → 0.1.308
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/package.json +1 -1
- package/src/claude/prompts.js +16 -0
- package/src/forge/repo-host.js +44 -14
- package/src/jira.js +19 -1
- package/src/memory.js +1 -0
- package/src/worker/forge-sync.js +27 -0
- package/src/worker/jira-helpers.js +41 -1
- package/src/worker/jira-issue-workflow.js +13 -1
- package/src/worker/pollers/draft-pull-requests.js +5 -5
- package/src/worker/pollers/jira.js +6 -4
- package/src/worker/pollers/review-pull-request-discussions.js +1 -1
- package/src/worker/pollers/review-pull-requests.js +1 -1
- package/src/worker/pollers/tracked-issues.js +23 -8
- package/src/worker/pr-session-store.js +70 -0
- package/src/worker/pull-request-workflow-support.js +15 -0
package/package.json
CHANGED
package/src/claude/prompts.js
CHANGED
|
@@ -179,6 +179,7 @@ export function buildJiraClarificationUserPrompt(input) {
|
|
|
179
179
|
referenceRepos: input.referenceRepos,
|
|
180
180
|
repoPaths: input.repoPaths
|
|
181
181
|
});
|
|
182
|
+
const attachmentsSection = formatIssueAttachments(input.issue.attachments);
|
|
182
183
|
|
|
183
184
|
return `Clarify this Jira issue.
|
|
184
185
|
|
|
@@ -189,6 +190,7 @@ Status: ${input.issue.status}
|
|
|
189
190
|
Description:
|
|
190
191
|
${input.issue.descriptionText || "(none)"}
|
|
191
192
|
|
|
193
|
+
${attachmentsSection}
|
|
192
194
|
Memory snapshot:
|
|
193
195
|
${JSON.stringify(input.memory, null, 2)}
|
|
194
196
|
|
|
@@ -202,6 +204,20 @@ ${reopenContext}
|
|
|
202
204
|
Decide whether the requirements are clear enough to plan implementation and whether the task needs code changes. Read code only if it helps.`;
|
|
203
205
|
}
|
|
204
206
|
|
|
207
|
+
// List attachments already present on the Jira issue so the agent reads
|
|
208
|
+
// material the user attached instead of asking for it. These are downloadable
|
|
209
|
+
// with jira_download_attachments by filename.
|
|
210
|
+
function formatIssueAttachments(attachments) {
|
|
211
|
+
const list = (Array.isArray(attachments) ? attachments : []).filter((item) => item && item.filename);
|
|
212
|
+
if (list.length === 0) {
|
|
213
|
+
return "";
|
|
214
|
+
}
|
|
215
|
+
const lines = list.map((item) => `- ${item.filename}${item.mimeType ? ` (${item.mimeType})` : ""}`).join("\n");
|
|
216
|
+
return `Issue attachments (download with jira_download_attachments before planning; read every one relevant to the task):
|
|
217
|
+
${lines}
|
|
218
|
+
`;
|
|
219
|
+
}
|
|
220
|
+
|
|
205
221
|
function formatClarificationRepoScope({ targetRepo, referenceRepos, repoPaths }) {
|
|
206
222
|
const repoPathLines = (Array.isArray(repoPaths) ? repoPaths : []).filter(Boolean);
|
|
207
223
|
|
package/src/forge/repo-host.js
CHANGED
|
@@ -66,6 +66,25 @@ export function getProviderLabel(provider) {
|
|
|
66
66
|
return provider === "gitlab" ? "GitLab" : "GitHub";
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Turn a host into an env-var suffix, e.g. gitlab.ignify.co -> GITLAB_IGNIFY_CO,
|
|
70
|
+
// so a self-hosted instance can carry its own token / CA bundle independent of
|
|
71
|
+
// the global one (fixes "token is not configured for <host>" and TLS failures
|
|
72
|
+
// against self-hosted GitLab).
|
|
73
|
+
function hostEnvSlug(host) {
|
|
74
|
+
return String(host || "")
|
|
75
|
+
.trim()
|
|
76
|
+
.toUpperCase()
|
|
77
|
+
.replace(/[^A-Z0-9]+/gu, "_")
|
|
78
|
+
.replace(/^_+|_+$/gu, "");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Resolve a config value preferring a per-host override over the global key.
|
|
82
|
+
function resolveHostScoped(config, baseKey, hostSlug) {
|
|
83
|
+
const scoped = hostSlug ? config[`${baseKey}_${hostSlug}`] || process.env[`${baseKey}_${hostSlug}`] : "";
|
|
84
|
+
const global = config[baseKey] || process.env[baseKey] || "";
|
|
85
|
+
return String(scoped || global || "").trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
69
88
|
export function buildGitEnvForRepoUrl(targetUrl = "https://github.com/", config = process.env) {
|
|
70
89
|
const env = {
|
|
71
90
|
...process.env,
|
|
@@ -84,24 +103,35 @@ export function buildGitEnvForRepoUrl(targetUrl = "https://github.com/", config
|
|
|
84
103
|
return env;
|
|
85
104
|
}
|
|
86
105
|
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
106
|
+
const hostSlug = hostEnvSlug(repo.host);
|
|
107
|
+
const tokenBaseKey = repo.provider === "gitlab" ? "GITLAB_TOKEN" : "GITHUB_PAT";
|
|
108
|
+
const token = resolveHostScoped(config, tokenBaseKey, hostSlug);
|
|
109
|
+
|
|
110
|
+
// CA bundle for self-hosted hosts behind a private/internal CA. Per-host
|
|
111
|
+
// override (GIT_SSL_CAINFO_<HOST>) wins over the global GIT_SSL_CAINFO.
|
|
112
|
+
const caBundle = resolveHostScoped(config, "GIT_SSL_CAINFO", hostSlug);
|
|
92
113
|
|
|
93
|
-
|
|
114
|
+
const configEntries = [];
|
|
115
|
+
if (token) {
|
|
116
|
+
const username = repo.provider === "gitlab" ? "oauth2" : "x-access-token";
|
|
117
|
+
const basicAuth = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
|
|
118
|
+
configEntries.push([`http.${repo.origin}/.extraheader`, `AUTHORIZATION: Basic ${basicAuth}`]);
|
|
119
|
+
}
|
|
120
|
+
if (caBundle) {
|
|
121
|
+
configEntries.push([`http.${repo.origin}/.sslCAInfo`, caBundle]);
|
|
122
|
+
env.GIT_SSL_CAINFO = caBundle;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (configEntries.length === 0) {
|
|
94
126
|
return env;
|
|
95
127
|
}
|
|
96
128
|
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
GIT_CONFIG_VALUE_0: `AUTHORIZATION: Basic ${basicAuth}`
|
|
104
|
-
};
|
|
129
|
+
const next = { ...env, GIT_CONFIG_COUNT: String(configEntries.length) };
|
|
130
|
+
configEntries.forEach(([key, value], i) => {
|
|
131
|
+
next[`GIT_CONFIG_KEY_${i}`] = key;
|
|
132
|
+
next[`GIT_CONFIG_VALUE_${i}`] = value;
|
|
133
|
+
});
|
|
134
|
+
return next;
|
|
105
135
|
}
|
|
106
136
|
|
|
107
137
|
export function extractRepoUrls(text) {
|
package/src/jira.js
CHANGED
|
@@ -306,10 +306,28 @@ function mapIssueDetail(issue, baseUrl) {
|
|
|
306
306
|
? issue.fields.comment.comments.map(mapComment).sort(compareCommentsByCreated)
|
|
307
307
|
: [];
|
|
308
308
|
|
|
309
|
+
// The detail fetch uses fields=*all, so existing attachments are already in
|
|
310
|
+
// the payload — surface them so the intake/planning step can download and
|
|
311
|
+
// read documents that users attach to the issue (a frequent "tài liệu đã
|
|
312
|
+
// đính kèm" complaint where the bot ignored material already on the task).
|
|
313
|
+
const attachments = Array.isArray(issue.fields?.attachment) ? issue.fields.attachment.map(mapAttachment) : [];
|
|
314
|
+
|
|
309
315
|
return {
|
|
310
316
|
...mapIssue(issue, baseUrl),
|
|
311
317
|
descriptionText: adfToText(issue.fields?.description).trim(),
|
|
312
|
-
comments
|
|
318
|
+
comments,
|
|
319
|
+
attachments
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function mapAttachment(attachment) {
|
|
324
|
+
return {
|
|
325
|
+
id: attachment?.id ?? null,
|
|
326
|
+
filename: attachment?.filename ?? "",
|
|
327
|
+
mimeType: attachment?.mimeType ?? "",
|
|
328
|
+
size: attachment?.size ?? null,
|
|
329
|
+
url: attachment?.content ?? null,
|
|
330
|
+
created: attachment?.created ?? null
|
|
313
331
|
};
|
|
314
332
|
}
|
|
315
333
|
|
package/src/memory.js
CHANGED
|
@@ -263,6 +263,7 @@ function normalizeIssueMemoryData(data) {
|
|
|
263
263
|
normalized.last_jira_memory_comment_id = String(normalized.last_jira_memory_comment_id ?? "").trim();
|
|
264
264
|
normalized.code_change_verdict = String(normalized.code_change_verdict ?? "").trim();
|
|
265
265
|
normalized.code_change_input_id = String(normalized.code_change_input_id ?? "").trim();
|
|
266
|
+
normalized.no_code_locked = Boolean(normalized.no_code_locked);
|
|
266
267
|
delete normalized.github_issue_url;
|
|
267
268
|
delete normalized.github_issue_number;
|
|
268
269
|
delete normalized.latest_processed_comment_id;
|
package/src/worker/forge-sync.js
CHANGED
|
@@ -60,6 +60,33 @@ export function getLatestGitHubComment(comments) {
|
|
|
60
60
|
return Array.isArray(comments) && comments.length > 0 ? comments[comments.length - 1] : null;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Post a one-shot "automatic retries paused" notice on a GitHub/GitLab issue
|
|
64
|
+
// when its circuit-breaker trips. Idempotent per CLAUDE.md: if the latest
|
|
65
|
+
// comment is already from the bot we skip, so the bot never stacks two
|
|
66
|
+
// consecutive comments across worker restarts / repeated trips.
|
|
67
|
+
export async function postGitHubBackoffNotice({ provider, botUser, repoUrl, issueNumber, count, logger }) {
|
|
68
|
+
try {
|
|
69
|
+
const detail = await provider.fetchIssue(repoUrl, issueNumber);
|
|
70
|
+
const latest = getLatestGitHubComment(detail.comments || []);
|
|
71
|
+
if (latest && isForgeBotAuthor(latest.author, botUser)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
await provider.postIssueComment(
|
|
75
|
+
repoUrl,
|
|
76
|
+
issueNumber,
|
|
77
|
+
`This task has failed ${count} times in a row, so I have paused automatic retries to avoid spamming. Please check the issue details and repository setup, then add a comment when it is ready for me to try again.`
|
|
78
|
+
);
|
|
79
|
+
return true;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
await logger?.error?.("Failed to post GitHub backoff notice (non-fatal)", {
|
|
82
|
+
repo: repoUrl,
|
|
83
|
+
issue: issueNumber,
|
|
84
|
+
error
|
|
85
|
+
});
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
export async function markGitHubThreadRead({ github, logger, repoUrl, issueNumber, commentId = "" }) {
|
|
64
91
|
try {
|
|
65
92
|
if (!commentId) {
|
|
@@ -185,11 +185,51 @@ export function shouldDisplayIssueInState(issue) {
|
|
|
185
185
|
});
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// Matches an explicit "no code" declaration in any of: "no code", "no_code",
|
|
189
|
+
// "no-code", "nocode" (case-insensitive). Users add this to tell the bot a task
|
|
190
|
+
// is documentation/estimate only and must NOT create a repo issue or push code.
|
|
191
|
+
const NO_CODE_MARKER = /\bno[\s_-]?code\b/iu;
|
|
192
|
+
|
|
193
|
+
// Deterministic no-code detection from explicit user signals, so the verdict no
|
|
194
|
+
// longer depends on the LLM guessing (which flipped to "code" and made users
|
|
195
|
+
// repeat "task no code" on every comment). Checks an explicit Jira label, the
|
|
196
|
+
// description, and the latest human comment.
|
|
197
|
+
export function detectNoCodeMarker(detail, botUser, jira) {
|
|
198
|
+
if (!detail) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const labels = Array.isArray(detail.labels) ? detail.labels : [];
|
|
203
|
+
if (labels.some((label) => /^no[\s_-]?code$/iu.test(String(label || "").trim()))) {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (NO_CODE_MARKER.test(String(detail.descriptionText || ""))) {
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const latestHumanComment = [...(Array.isArray(detail.comments) ? detail.comments : [])]
|
|
212
|
+
.filter((comment) => !String(comment?.bodyText || "").startsWith(PROGRESS_COMMENT_PREFIX))
|
|
213
|
+
.sort(compareCommentsNewestFirst)
|
|
214
|
+
.find((comment) => (botUser ? !jira.isBotAuthor(comment.author, botUser) : true));
|
|
215
|
+
|
|
216
|
+
return Boolean(latestHumanComment && NO_CODE_MARKER.test(String(latestHumanComment.bodyText || "")));
|
|
217
|
+
}
|
|
218
|
+
|
|
188
219
|
export function shouldReuseNoCodeDecision({ issueMemory, latestInputId }) {
|
|
220
|
+
const noGithubIssues = !Array.isArray(issueMemory?.github_issues) || issueMemory.github_issues.length === 0;
|
|
221
|
+
|
|
222
|
+
// Once an explicit no-code marker has locked the issue, keep the no-code
|
|
223
|
+
// verdict sticky across subsequent comments (new input ids) so a follow-up
|
|
224
|
+
// that does not repeat "no code" is not re-evaluated back into a code task.
|
|
225
|
+
if (issueMemory?.no_code_locked && noGithubIssues) {
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
189
229
|
return (
|
|
190
230
|
issueMemory?.code_change_verdict === "no_code" &&
|
|
191
231
|
issueMemory?.code_change_input_id === latestInputId &&
|
|
192
|
-
|
|
232
|
+
noGithubIssues
|
|
193
233
|
);
|
|
194
234
|
}
|
|
195
235
|
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
cleanupJiraProgressComments,
|
|
23
23
|
deriveRawTaskRepos,
|
|
24
24
|
deriveTaskRepos,
|
|
25
|
+
detectNoCodeMarker,
|
|
25
26
|
ensureJiraComment,
|
|
26
27
|
getLatestBlockedTaskRestartComment,
|
|
27
28
|
getLatestClarificationInput,
|
|
@@ -632,7 +633,18 @@ export async function processJiraIssue({
|
|
|
632
633
|
issueMemory,
|
|
633
634
|
latestInputId: latestInput.id
|
|
634
635
|
});
|
|
635
|
-
if (
|
|
636
|
+
if (detectNoCodeMarker(detail, botUser, jira)) {
|
|
637
|
+
// Explicit user signal ("task no code" / no-code label) overrides the
|
|
638
|
+
// LLM verdict and locks the issue so later comments stay no-code. This
|
|
639
|
+
// stops the bot from re-deciding "code" and creating a repo issue, which
|
|
640
|
+
// forced users to repeat "task no code" on every comment.
|
|
641
|
+
decisionResult = { verdict: "no_code" };
|
|
642
|
+
issueMemory.no_code_locked = true;
|
|
643
|
+
await logger.info("Explicit no-code marker detected; forcing no_code verdict", {
|
|
644
|
+
issue: detail.key,
|
|
645
|
+
input: latestInput.id
|
|
646
|
+
});
|
|
647
|
+
} else if (shouldReusePreservedNoCodeDecision) {
|
|
636
648
|
decisionResult = { verdict: "no_code" };
|
|
637
649
|
await logger.info("Reusing preserved no-code decision", {
|
|
638
650
|
issue: detail.key,
|
|
@@ -63,7 +63,7 @@ export function createDraftPullRequestPoller({
|
|
|
63
63
|
return true;
|
|
64
64
|
}
|
|
65
65
|
if (stuckAt) {
|
|
66
|
-
await logger.
|
|
66
|
+
await logger.debug("Draft PR is stuck but retry window has not elapsed, not queueing for work", {
|
|
67
67
|
pr: pullRequest.number,
|
|
68
68
|
repo: pullRequest.repoUrl
|
|
69
69
|
});
|
|
@@ -83,7 +83,7 @@ export function createDraftPullRequestPoller({
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
if (hasEyesReaction(latestComment) || hasPlusOneReaction(latestComment)) {
|
|
86
|
-
await logger.
|
|
86
|
+
await logger.debug("Draft PR latest comment already acknowledged, not queueing for work", {
|
|
87
87
|
pr: pullRequest.number,
|
|
88
88
|
repo: pullRequest.repoUrl
|
|
89
89
|
});
|
|
@@ -92,7 +92,7 @@ export function createDraftPullRequestPoller({
|
|
|
92
92
|
|
|
93
93
|
const isMember = await checkCommentAuthorIsMember(provider, repo, latestComment, logger, pullRequest.number, true);
|
|
94
94
|
if (!isMember) {
|
|
95
|
-
await logger.
|
|
95
|
+
await logger.debug("Draft PR latest comment author is not a repo member, not queueing for work", {
|
|
96
96
|
pr: pullRequest.number,
|
|
97
97
|
repo: pullRequest.repoUrl
|
|
98
98
|
});
|
|
@@ -100,7 +100,7 @@ export function createDraftPullRequestPoller({
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
if (isPullRequestReviewRequestComment(latestComment.body, botUser)) {
|
|
103
|
-
await logger.
|
|
103
|
+
await logger.debug("Draft PR latest comment is only a review request, not queueing for work", {
|
|
104
104
|
pr: pullRequest.number,
|
|
105
105
|
repo: pullRequest.repoUrl
|
|
106
106
|
});
|
|
@@ -114,7 +114,7 @@ export function createDraftPullRequestPoller({
|
|
|
114
114
|
const taskId = buildPullRequestQueueId(pullRequest.repoUrl, pullRequest.number);
|
|
115
115
|
|
|
116
116
|
if (inFlight.has(taskId)) {
|
|
117
|
-
await logger.
|
|
117
|
+
await logger.debug("Draft PR already queued or in-flight, skipping", {
|
|
118
118
|
pr: pullRequest.number,
|
|
119
119
|
repo: pullRequest.repoUrl
|
|
120
120
|
});
|
|
@@ -45,7 +45,7 @@ export function createJiraPoller({
|
|
|
45
45
|
|
|
46
46
|
async function shouldDispatchIssue(issue) {
|
|
47
47
|
if (hasPendingJiraWorkLabel(issue)) {
|
|
48
|
-
await logger.
|
|
48
|
+
await logger.debug("Jira issue waiting on external input, not queueing for work", { issue: issue.key });
|
|
49
49
|
return { dispatch: false, detail: null };
|
|
50
50
|
}
|
|
51
51
|
|
|
@@ -70,7 +70,9 @@ export function createJiraPoller({
|
|
|
70
70
|
}
|
|
71
71
|
const shouldResume = hasNewHumanReplyWhileWaiting(detail, jiraBotUser, jira);
|
|
72
72
|
if (!shouldResume) {
|
|
73
|
-
await logger.
|
|
73
|
+
await logger.debug("Jira issue is in review with no new human reply, not queueing for work", {
|
|
74
|
+
issue: issue.key
|
|
75
|
+
});
|
|
74
76
|
}
|
|
75
77
|
return { dispatch: shouldResume, detail: shouldResume ? detail : null };
|
|
76
78
|
}
|
|
@@ -89,7 +91,7 @@ export function createJiraPoller({
|
|
|
89
91
|
state.pollerCurrent.jira = String(issue?.key || "");
|
|
90
92
|
|
|
91
93
|
if (inFlight.has(issue.key)) {
|
|
92
|
-
await logger.
|
|
94
|
+
await logger.debug("Jira issue already queued or in-flight, skipping", { issue: issue.key });
|
|
93
95
|
continue;
|
|
94
96
|
}
|
|
95
97
|
|
|
@@ -105,7 +107,7 @@ export function createJiraPoller({
|
|
|
105
107
|
await persistState();
|
|
106
108
|
}
|
|
107
109
|
} else if (isIssueInBackoff(state, issue.key)) {
|
|
108
|
-
await logger.
|
|
110
|
+
await logger.debug("Jira issue in failure backoff, skipping until retry window elapses", {
|
|
109
111
|
issue: issue.key
|
|
110
112
|
});
|
|
111
113
|
continue;
|
|
@@ -68,7 +68,7 @@ export function createReviewPullRequestDiscussionPoller({
|
|
|
68
68
|
for (const pr of prsWithThreads) {
|
|
69
69
|
const taskId = buildTaskId(pr.repoUrl, pr.number);
|
|
70
70
|
if (inFlight.has(taskId)) {
|
|
71
|
-
await logger.
|
|
71
|
+
await logger.debug("Suggestion revision already in-flight, skipping", {
|
|
72
72
|
pr: pr.number,
|
|
73
73
|
repo: pr.repoUrl
|
|
74
74
|
});
|
|
@@ -66,7 +66,7 @@ export function createReviewPullRequestPoller({
|
|
|
66
66
|
const taskId = buildPullRequestQueueId(pr.repoUrl, pr.number);
|
|
67
67
|
|
|
68
68
|
if (inFlight.has(taskId)) {
|
|
69
|
-
await logger.
|
|
69
|
+
await logger.debug("PR review already queued or in-flight, skipping", {
|
|
70
70
|
pr: pr.number,
|
|
71
71
|
repo: pr.repoUrl
|
|
72
72
|
});
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { listIssueMemoryRecords } from "../../memory.js";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getForgeBotUserForRepo,
|
|
4
|
+
getLatestGitHubComment,
|
|
5
|
+
isForgeBotAuthor,
|
|
6
|
+
postGitHubBackoffNotice
|
|
7
|
+
} from "../forge-sync.js";
|
|
3
8
|
import { clearIssueBackoff, isIssueInBackoff, recordIssueFailure } from "../issue-backoff.js";
|
|
4
9
|
import { checkCommentAuthorIsMember, hasEyesReaction, hasPlusOneReaction, isApprovalComment } from "../pull-request.js";
|
|
5
10
|
import { normalizeRepoIdentity } from "../repo-utils.js";
|
|
@@ -60,7 +65,7 @@ export function createTrackedIssuePoller({
|
|
|
60
65
|
`${normalizeRepoIdentity(repo.url || "")}#${String(issue.number || "").trim()}`
|
|
61
66
|
);
|
|
62
67
|
if (!issueMemoryRecord) {
|
|
63
|
-
await logger.
|
|
68
|
+
await logger.debug("GitHub issue has no linked Jira memory, not queueing for work", {
|
|
64
69
|
issue: issue.number,
|
|
65
70
|
repo: issue.repoUrl
|
|
66
71
|
});
|
|
@@ -78,7 +83,7 @@ export function createTrackedIssuePoller({
|
|
|
78
83
|
}
|
|
79
84
|
|
|
80
85
|
if (hasEyesReaction(latestComment) || hasPlusOneReaction(latestComment)) {
|
|
81
|
-
await logger.
|
|
86
|
+
await logger.debug("GitHub issue latest comment already acknowledged, not queueing for work", {
|
|
82
87
|
issue: issue.number,
|
|
83
88
|
repo: issue.repoUrl
|
|
84
89
|
});
|
|
@@ -94,7 +99,7 @@ export function createTrackedIssuePoller({
|
|
|
94
99
|
linkedGitHubIssue &&
|
|
95
100
|
String(linkedGitHubIssue.last_issue_memory_comment_id || "") === String(latestComment.id)
|
|
96
101
|
) {
|
|
97
|
-
await logger.
|
|
102
|
+
await logger.debug("GitHub issue latest comment already processed (memory stamp), not queueing for work", {
|
|
98
103
|
issue: issue.number,
|
|
99
104
|
repo: issue.repoUrl
|
|
100
105
|
});
|
|
@@ -103,7 +108,7 @@ export function createTrackedIssuePoller({
|
|
|
103
108
|
|
|
104
109
|
const isMember = await checkCommentAuthorIsMember(provider, repo, latestComment, logger, issue.number, false);
|
|
105
110
|
if (!isMember) {
|
|
106
|
-
await logger.
|
|
111
|
+
await logger.debug("GitHub issue latest comment author is not a repo member, not queueing for work", {
|
|
107
112
|
issue: issue.number,
|
|
108
113
|
repo: issue.repoUrl
|
|
109
114
|
});
|
|
@@ -147,7 +152,7 @@ export function createTrackedIssuePoller({
|
|
|
147
152
|
const taskId = buildIssueQueueId(issue.repoUrl, issue.number);
|
|
148
153
|
|
|
149
154
|
if (inFlight.has(taskId)) {
|
|
150
|
-
await logger.
|
|
155
|
+
await logger.debug("GitHub issue already queued or in-flight, skipping", {
|
|
151
156
|
issue: issue.number,
|
|
152
157
|
repo: issue.repoUrl
|
|
153
158
|
});
|
|
@@ -163,7 +168,7 @@ export function createTrackedIssuePoller({
|
|
|
163
168
|
// human signal); otherwise skip while the issue is in its retry-backoff
|
|
164
169
|
// window so a persistently failing issue is not re-dispatched every poll.
|
|
165
170
|
if (isIssueInBackoff(state, taskId, { trigger: decision.commentId })) {
|
|
166
|
-
await logger.
|
|
171
|
+
await logger.debug("GitHub issue in failure backoff, skipping until retry window elapses", {
|
|
167
172
|
issue: issue.number,
|
|
168
173
|
repo: issue.repoUrl
|
|
169
174
|
});
|
|
@@ -195,13 +200,23 @@ export function createTrackedIssuePoller({
|
|
|
195
200
|
await processIssue({ issue, repo: repoWithPath, provider, botUser, taskId });
|
|
196
201
|
clearIssueBackoff(state, taskId);
|
|
197
202
|
} catch (error) {
|
|
198
|
-
const { count } = recordIssueFailure(state, taskId, { trigger: decision.commentId });
|
|
203
|
+
const { count, shouldNotify } = recordIssueFailure(state, taskId, { trigger: decision.commentId });
|
|
199
204
|
await logger.error("GitHub issue task failed", {
|
|
200
205
|
issue: issue.number,
|
|
201
206
|
repo: issue.repoUrl,
|
|
202
207
|
error,
|
|
203
208
|
failureCount: count
|
|
204
209
|
});
|
|
210
|
+
if (shouldNotify) {
|
|
211
|
+
await postGitHubBackoffNotice({
|
|
212
|
+
provider,
|
|
213
|
+
botUser,
|
|
214
|
+
repoUrl: issue.repoUrl,
|
|
215
|
+
issueNumber: issue.number,
|
|
216
|
+
count,
|
|
217
|
+
logger
|
|
218
|
+
});
|
|
219
|
+
}
|
|
205
220
|
} finally {
|
|
206
221
|
inFlight.delete(taskId);
|
|
207
222
|
removeWorkQueueItem(state, workQueueItem.id);
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { atomicWriteFile } from "../fs-atomic.js";
|
|
4
|
+
|
|
5
|
+
// Durable map of Claude session ids keyed by repo + PR branch, so a PR comment
|
|
6
|
+
// or reopen can resume the prior conversation instead of rebuilding context
|
|
7
|
+
// from memory (which previously dropped diffs / decisions and caused the model
|
|
8
|
+
// to "forget" earlier work). Stored under the same memory dir as issue memory
|
|
9
|
+
// so it survives worker restarts.
|
|
10
|
+
const SESSIONS_FILE = "pr-sessions.json";
|
|
11
|
+
const MAX_ENTRIES = 1000;
|
|
12
|
+
|
|
13
|
+
function sessionStorePath(projectRoot) {
|
|
14
|
+
return path.join(projectRoot, "memory", SESSIONS_FILE);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sessionKey(repoUrl, branchName) {
|
|
18
|
+
return `${String(repoUrl || "").trim()}#${String(branchName || "").trim()}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readStore(projectRoot) {
|
|
22
|
+
try {
|
|
23
|
+
const raw = await readFile(sessionStorePath(projectRoot), "utf8");
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
26
|
+
} catch {
|
|
27
|
+
// Missing file / corrupt JSON → start from empty; resume is best-effort.
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function loadPrSessionId(projectRoot, repoUrl, branchName) {
|
|
33
|
+
if (!projectRoot || !branchName) {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
const store = await readStore(projectRoot);
|
|
37
|
+
const entry = store[sessionKey(repoUrl, branchName)];
|
|
38
|
+
return entry && typeof entry.session_id === "string" ? entry.session_id : "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Serialize read-modify-write so concurrent PR tasks writing the shared file
|
|
42
|
+
// never lose each other's entries.
|
|
43
|
+
let writeChain = Promise.resolve();
|
|
44
|
+
|
|
45
|
+
export async function savePrSessionId(projectRoot, repoUrl, branchName, sessionId) {
|
|
46
|
+
if (!projectRoot || !branchName || !sessionId) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
writeChain = writeChain
|
|
50
|
+
.then(async () => {
|
|
51
|
+
const store = await readStore(projectRoot);
|
|
52
|
+
store[sessionKey(repoUrl, branchName)] = {
|
|
53
|
+
session_id: String(sessionId).trim(),
|
|
54
|
+
updated_at: new Date().toISOString()
|
|
55
|
+
};
|
|
56
|
+
// Bound file growth: drop oldest entries beyond the cap.
|
|
57
|
+
const keys = Object.keys(store);
|
|
58
|
+
if (keys.length > MAX_ENTRIES) {
|
|
59
|
+
keys
|
|
60
|
+
.sort((a, b) => String(store[a].updated_at || "").localeCompare(String(store[b].updated_at || "")))
|
|
61
|
+
.slice(0, keys.length - MAX_ENTRIES)
|
|
62
|
+
.forEach((k) => delete store[k]);
|
|
63
|
+
}
|
|
64
|
+
const file = sessionStorePath(projectRoot);
|
|
65
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
66
|
+
await atomicWriteFile(file, JSON.stringify(store, null, 2));
|
|
67
|
+
})
|
|
68
|
+
.catch(() => {});
|
|
69
|
+
return writeChain;
|
|
70
|
+
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
setForgePullRequestBlocked,
|
|
21
21
|
transitionLinkedJiraIssueToReview
|
|
22
22
|
} from "./forge-sync.js";
|
|
23
|
+
import { loadPrSessionId, savePrSessionId } from "./pr-session-store.js";
|
|
23
24
|
import {
|
|
24
25
|
allPlanStepsDone,
|
|
25
26
|
buildDraftPrState,
|
|
@@ -180,6 +181,16 @@ export async function processPullRequestImplementation({
|
|
|
180
181
|
const issueKey = githubIssueMemory?.jira_key || `PR-${detail.number}`;
|
|
181
182
|
const epicSnapshot = buildEpicMemoryPromptSnapshot(epicMemory);
|
|
182
183
|
|
|
184
|
+
// Resume the prior Claude conversation for this PR branch so a follow-up
|
|
185
|
+
// comment / reopen keeps the model's earlier context instead of rebuilding
|
|
186
|
+
// it from memory (which dropped diffs/decisions and caused re-work). The
|
|
187
|
+
// branchDiff + latestComment below remain as the fallback when no session
|
|
188
|
+
// id is stored or the stored id is stale.
|
|
189
|
+
const resumeSessionId = await loadPrSessionId(projectRoot, repo.url, detail.headRef);
|
|
190
|
+
const onSessionId = (sessionId) => {
|
|
191
|
+
void savePrSessionId(projectRoot, repo.url, detail.headRef, sessionId);
|
|
192
|
+
};
|
|
193
|
+
|
|
183
194
|
// Re-ground the run on any work earlier runs already committed to this
|
|
184
195
|
// branch. Without this, a re-invocation triggered by a PR comment loses the
|
|
185
196
|
// context of what was done and re-implements or reverts prior changes.
|
|
@@ -266,6 +277,8 @@ export async function processPullRequestImplementation({
|
|
|
266
277
|
timeoutMs: parseOptionalInt(config.CLAUDE_TIMEOUT_MS),
|
|
267
278
|
issueKey,
|
|
268
279
|
logger,
|
|
280
|
+
resumeSessionId,
|
|
281
|
+
onSessionId,
|
|
269
282
|
onSpawn: (child) => {
|
|
270
283
|
void updatePrSubtaskState("prImplementation", {
|
|
271
284
|
phase: "implementation",
|
|
@@ -315,6 +328,8 @@ export async function processPullRequestImplementation({
|
|
|
315
328
|
timeoutMs: parseOptionalInt(config.CLAUDE_TIMEOUT_MS),
|
|
316
329
|
issueKey,
|
|
317
330
|
logger,
|
|
331
|
+
resumeSessionId,
|
|
332
|
+
onSessionId,
|
|
318
333
|
onSpawn: (child) => {
|
|
319
334
|
void updatePrSubtaskState("prImplementation", {
|
|
320
335
|
phase: "implementation",
|