claude-teammate 0.1.305 → 0.1.306
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/process.js +45 -4
- package/src/claude/prompts.js +25 -2
- package/src/claude/stream.js +18 -0
- package/src/claude.js +82 -16
- package/src/forge/gitlab.js +65 -28
- package/src/forge/repo-host.js +26 -6
- package/src/fs-atomic.js +23 -0
- package/src/logger.js +92 -4
- package/src/memory.js +5 -10
- package/src/repo.js +84 -8
- package/src/runtime.js +3 -4
- package/src/worker/forge-sync.js +7 -1
- package/src/worker/github-issue-workflow.js +23 -4
- package/src/worker/pollers/draft-pull-requests.js +1 -1
- package/src/worker/pollers/tracked-issues.js +1 -1
- package/src/worker/pull-request-workflow-support.js +12 -0
- package/src/worker/pull-request.js +60 -0
package/package.json
CHANGED
package/src/claude/process.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
|
|
3
3
|
const CHILD_CLEANUP_WAIT_MS = 1_000;
|
|
4
|
-
const CLAUDE_MAX_ATTEMPTS =
|
|
4
|
+
const CLAUDE_MAX_ATTEMPTS = 3;
|
|
5
|
+
const CLAUDE_RETRY_BASE_DELAY_MS = 2_000;
|
|
5
6
|
const DEFAULT_CLAUDE_PERMISSION_MODE = "bypassPermissions";
|
|
6
7
|
|
|
7
8
|
export class ClaudeCliError extends Error {
|
|
@@ -53,8 +54,44 @@ export function formatClaudeInvocationError(error, timeoutMs) {
|
|
|
53
54
|
return `Claude CLI invocation failed${timeout ? ` after ${timeoutMs}ms` : ""}${signal ? ` (${signal})` : ""}${codeFragment}${details ? `: ${details}` : "."}`;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
// Resource/transport failures that a fresh attempt can plausibly clear:
|
|
58
|
+
// fork failures under VM load (EAGAIN/ENOMEM/ENFILE/EMFILE), OOM-kills
|
|
59
|
+
// (SIGKILL), and timeouts. Deterministic failures must NOT retry: a usage
|
|
60
|
+
// limit, an over-long prompt, or a clean non-zero exit (a logic/permission
|
|
61
|
+
// error from a CLI that actually ran) will fail again identically and just
|
|
62
|
+
// burn quota.
|
|
63
|
+
const TRANSIENT_SPAWN_ERRNOS = new Set(["EAGAIN", "ENOMEM", "ENFILE", "EMFILE", "ECONNRESET", "ETIMEDOUT"]);
|
|
64
|
+
|
|
65
|
+
export function isTransientClaudeError(error) {
|
|
66
|
+
if (!error || typeof error !== "object") {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
if (error.hitUsageLimit || error.promptTooLong) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
if (error.killed) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
if (typeof error.code === "string" && TRANSIENT_SPAWN_ERRNOS.has(error.code)) {
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
// External SIGKILL with no captured output is the classic OOM-killer
|
|
79
|
+
// signature; SIGTERM here is our own maxBuffer/limit kill and is handled
|
|
80
|
+
// by the flags above, so only treat SIGKILL as transient.
|
|
81
|
+
if (error.signal === "SIGKILL") {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function shouldRetryClaudeCommand(options = {}, attempt, error) {
|
|
88
|
+
return !options.noRetry && attempt < CLAUDE_MAX_ATTEMPTS && isTransientClaudeError(error);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function delay(ms) {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
setTimeout(resolve, ms);
|
|
94
|
+
});
|
|
58
95
|
}
|
|
59
96
|
|
|
60
97
|
export async function runClaudeCommand(command, args, options) {
|
|
@@ -62,9 +99,13 @@ export async function runClaudeCommand(command, args, options) {
|
|
|
62
99
|
try {
|
|
63
100
|
return await runClaudeCommandOnce(command, args, options);
|
|
64
101
|
} catch (error) {
|
|
65
|
-
if (!shouldRetryClaudeCommand(options, attempt)) {
|
|
102
|
+
if (!shouldRetryClaudeCommand(options, attempt, error)) {
|
|
66
103
|
throw error;
|
|
67
104
|
}
|
|
105
|
+
options.onRetry?.({ attempt, error });
|
|
106
|
+
// Exponential backoff so a transient spike (fork storm, OOM) gets a
|
|
107
|
+
// moment to subside before the next attempt.
|
|
108
|
+
await delay(CLAUDE_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1));
|
|
68
109
|
}
|
|
69
110
|
}
|
|
70
111
|
}
|
package/src/claude/prompts.js
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
import { formatAdditionalRepoPaths } from "./paths.js";
|
|
2
2
|
|
|
3
|
+
// Re-ground a re-invoked implementation run on what an earlier run already did.
|
|
4
|
+
// Both fields are optional; when neither is present this contributes nothing so
|
|
5
|
+
// first runs are unchanged. The diff is truncated by the caller to stay within
|
|
6
|
+
// the prompt budget.
|
|
7
|
+
function formatPriorWorkContext(input) {
|
|
8
|
+
const sections = [];
|
|
9
|
+
const priorSummary = String(input.priorSummary || "").trim();
|
|
10
|
+
if (priorSummary) {
|
|
11
|
+
sections.push(`\nPrior run summary (what you reported last time):\n${priorSummary}`);
|
|
12
|
+
}
|
|
13
|
+
const branchDiff = String(input.branchDiff || "").trim();
|
|
14
|
+
if (branchDiff) {
|
|
15
|
+
sections.push(`\nWork already done on this branch (diff vs base):\n${branchDiff}`);
|
|
16
|
+
}
|
|
17
|
+
if (sections.length === 0) {
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
sections.push(
|
|
21
|
+
"\nBuild on the work above; do not redo or revert changes that already satisfy the plan unless the latest comment asks for it."
|
|
22
|
+
);
|
|
23
|
+
return `${sections.join("\n")}\n`;
|
|
24
|
+
}
|
|
25
|
+
|
|
3
26
|
const INCLUDE_RESOURCE_LINKS_RULE =
|
|
4
27
|
"When your response is posted as a comment on a Jira issue, GitHub issue, or GitHub PR, include the URL of every resource you created (e.g. Jira task, spreadsheet, test design, test cases, document, Confluence page) so the reader can navigate directly to it.";
|
|
5
28
|
|
|
@@ -353,7 +376,7 @@ ${input.latestComment?.body || "(none)"}
|
|
|
353
376
|
|
|
354
377
|
Recent PR comments:
|
|
355
378
|
${recentComments || "(none)"}
|
|
356
|
-
|
|
379
|
+
${formatPriorWorkContext(input)}
|
|
357
380
|
Instructions:
|
|
358
381
|
- Checkout the branch above in this repository.
|
|
359
382
|
- Implement the plan described in the pull request body.
|
|
@@ -469,7 +492,7 @@ ${JSON.stringify(input.memory?.epic || {}, null, 2)}
|
|
|
469
492
|
|
|
470
493
|
Step to implement:
|
|
471
494
|
${input.step}
|
|
472
|
-
|
|
495
|
+
${formatPriorWorkContext(input)}
|
|
473
496
|
Instructions:
|
|
474
497
|
- Checkout the branch above in this repository.
|
|
475
498
|
- Implement only the step described above. Do not implement other parts of the plan.
|
package/src/claude/stream.js
CHANGED
|
@@ -26,6 +26,24 @@ export function formatStreamEvent(event) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Pull the Claude session id out of stream-json events so the caller can
|
|
30
|
+
// persist it and resume the same conversation on a later run (e.g. when a
|
|
31
|
+
// developer comments on the PR). Both `system` (init) and `result` events
|
|
32
|
+
// carry session_id; the last one wins.
|
|
33
|
+
export function extractSessionId(lines) {
|
|
34
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
35
|
+
try {
|
|
36
|
+
const event = JSON.parse(lines[i]);
|
|
37
|
+
if (event && typeof event.session_id === "string" && event.session_id) {
|
|
38
|
+
return event.session_id;
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// skip non-JSON lines
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return "";
|
|
45
|
+
}
|
|
46
|
+
|
|
29
47
|
export function extractResultFromStreamJson(lines) {
|
|
30
48
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
31
49
|
try {
|
package/src/claude.js
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
cleanupClaudeProcesses,
|
|
14
14
|
formatClaudeInvocationError,
|
|
15
15
|
isClaudeCliError,
|
|
16
|
+
isTransientClaudeError,
|
|
16
17
|
resolveClaudePermissionMode,
|
|
17
18
|
runClaudeCommand,
|
|
18
19
|
shouldRetryClaudeCommand
|
|
@@ -45,7 +46,12 @@ import {
|
|
|
45
46
|
buildSuggestionRevisionSystemPrompt,
|
|
46
47
|
buildSuggestionRevisionUserPrompt
|
|
47
48
|
} from "./claude/prompts.js";
|
|
48
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
extractResultFromStreamJson,
|
|
51
|
+
extractSessionId,
|
|
52
|
+
formatStreamEvent,
|
|
53
|
+
parseClaudeOutput
|
|
54
|
+
} from "./claude/stream.js";
|
|
49
55
|
import {
|
|
50
56
|
validateClaudeResult,
|
|
51
57
|
validateEpicMemoryCleanupResult,
|
|
@@ -76,6 +82,7 @@ export {
|
|
|
76
82
|
hasPlaywrightMcpConfigured,
|
|
77
83
|
isClaudeCliError,
|
|
78
84
|
isHeadlessPlaywrightMcpConfig,
|
|
85
|
+
isTransientClaudeError,
|
|
79
86
|
parseClaudeOutput,
|
|
80
87
|
shouldRetryClaudeCommand
|
|
81
88
|
};
|
|
@@ -380,10 +387,39 @@ const REPO_EXTRACTION_SCHEMA = {
|
|
|
380
387
|
required: ["repos", "pr_repo_url"]
|
|
381
388
|
};
|
|
382
389
|
|
|
390
|
+
// A `--resume <id>` invocation failed specifically because the session could
|
|
391
|
+
// not be loaded (worker moved hosts, local session store cleared, id stale).
|
|
392
|
+
// Distinct from a real task failure so we only fall back to a fresh run for
|
|
393
|
+
// the recoverable case.
|
|
394
|
+
export function isResumeSessionError(error) {
|
|
395
|
+
if (!error || typeof error !== "object") {
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
const text = `${error.stderr || ""}\n${error.stdout || ""}\n${error.message || ""}`.toLowerCase();
|
|
399
|
+
return (
|
|
400
|
+
text.includes("no conversation found") ||
|
|
401
|
+
text.includes("session not found") ||
|
|
402
|
+
text.includes("could not find session") ||
|
|
403
|
+
text.includes("no session") ||
|
|
404
|
+
(text.includes("resume") && text.includes("not found"))
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function wrapClaudeCliError(error, timeoutMs) {
|
|
409
|
+
const cliError = new ClaudeCliError(formatClaudeInvocationError(error, timeoutMs));
|
|
410
|
+
if (error?.hitUsageLimit) {
|
|
411
|
+
cliError.hitUsageLimit = true;
|
|
412
|
+
}
|
|
413
|
+
if (error?.promptTooLong) {
|
|
414
|
+
cliError.promptTooLong = true;
|
|
415
|
+
}
|
|
416
|
+
return cliError;
|
|
417
|
+
}
|
|
418
|
+
|
|
383
419
|
async function invokeClaudeTask(schema, systemPrompt, userPrompt, opts = {}) {
|
|
384
420
|
const { model, permissionMode, effort, extraArgs = [], validate, runOpts = {} } = opts;
|
|
385
421
|
|
|
386
|
-
const
|
|
422
|
+
const baseArgs = [
|
|
387
423
|
"--print",
|
|
388
424
|
"--model",
|
|
389
425
|
model || DEFAULT_MODEL,
|
|
@@ -400,14 +436,21 @@ async function invokeClaudeTask(schema, systemPrompt, userPrompt, opts = {}) {
|
|
|
400
436
|
userPrompt
|
|
401
437
|
];
|
|
402
438
|
|
|
403
|
-
const { issueKey, logger, phase, epicContext, ...restRunOpts } = runOpts;
|
|
439
|
+
const { issueKey, logger, phase, epicContext, resumeSessionId, onSessionId, ...restRunOpts } = runOpts;
|
|
404
440
|
|
|
405
441
|
// Always use stream-json so we have full event data for skill failure detection.
|
|
406
442
|
const loggingEnabled = Boolean(issueKey && logger && typeof logger.issueLog === "function");
|
|
407
|
-
|
|
443
|
+
|
|
444
|
+
// Resume the prior conversation when a session id is supplied so the model
|
|
445
|
+
// keeps its earlier context (e.g. responding to a PR comment). `--resume`
|
|
446
|
+
// is prepended; if it fails because the session is gone we fall back to a
|
|
447
|
+
// fresh run below so a stale id never permanently blocks the task.
|
|
448
|
+
const resumeId = String(resumeSessionId || "").trim();
|
|
449
|
+
const buildArgs = (withResume) =>
|
|
450
|
+
buildStreamArgs(withResume && resumeId ? ["--resume", resumeId, ...baseArgs] : baseArgs);
|
|
408
451
|
|
|
409
452
|
const collectedLines = [];
|
|
410
|
-
const
|
|
453
|
+
const makeOnData = () => (chunk) => {
|
|
411
454
|
const lines = String(chunk)
|
|
412
455
|
.split("\n")
|
|
413
456
|
.filter((l) => l.trim());
|
|
@@ -427,21 +470,40 @@ async function invokeClaudeTask(schema, systemPrompt, userPrompt, opts = {}) {
|
|
|
427
470
|
}
|
|
428
471
|
};
|
|
429
472
|
|
|
430
|
-
|
|
431
|
-
|
|
473
|
+
const runOnce = (withResume) => {
|
|
474
|
+
collectedLines.length = 0;
|
|
475
|
+
return runClaudeCommand("claude", buildArgs(withResume), {
|
|
432
476
|
maxBuffer: 10 * 1024 * 1024,
|
|
433
477
|
...restRunOpts,
|
|
434
|
-
onData
|
|
478
|
+
onData: makeOnData()
|
|
435
479
|
});
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
await runOnce(Boolean(resumeId));
|
|
436
484
|
} catch (error) {
|
|
437
|
-
|
|
438
|
-
if (error
|
|
439
|
-
|
|
485
|
+
// A stale/missing session is recoverable: retry once from a clean slate.
|
|
486
|
+
if (resumeId && isResumeSessionError(error)) {
|
|
487
|
+
void logger?.info?.("Claude session resume failed; retrying without resume", { issueKey, phase });
|
|
488
|
+
try {
|
|
489
|
+
await runOnce(false);
|
|
490
|
+
} catch (retryError) {
|
|
491
|
+
throw wrapClaudeCliError(retryError, runOpts.timeout);
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
throw wrapClaudeCliError(error, runOpts.timeout);
|
|
440
495
|
}
|
|
441
|
-
|
|
442
|
-
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (typeof onSessionId === "function") {
|
|
499
|
+
const sessionId = extractSessionId(collectedLines);
|
|
500
|
+
if (sessionId) {
|
|
501
|
+
try {
|
|
502
|
+
onSessionId(sessionId);
|
|
503
|
+
} catch {
|
|
504
|
+
// Persisting the session id is best-effort; never fail the task on it.
|
|
505
|
+
}
|
|
443
506
|
}
|
|
444
|
-
throw cliError;
|
|
445
507
|
}
|
|
446
508
|
|
|
447
509
|
const outputToParse = extractResultFromStreamJson(collectedLines);
|
|
@@ -787,7 +849,9 @@ export async function runClaudeImplementation(input) {
|
|
|
787
849
|
issueKey: input.issueKey,
|
|
788
850
|
logger: input.logger,
|
|
789
851
|
phase: "implementation",
|
|
790
|
-
epicContext: input.epicContext
|
|
852
|
+
epicContext: input.epicContext,
|
|
853
|
+
resumeSessionId: input.resumeSessionId,
|
|
854
|
+
onSessionId: input.onSessionId
|
|
791
855
|
}
|
|
792
856
|
}
|
|
793
857
|
);
|
|
@@ -870,7 +934,9 @@ export async function runClaudeImplementationStep(input) {
|
|
|
870
934
|
issueKey: input.issueKey,
|
|
871
935
|
logger: input.logger,
|
|
872
936
|
phase: "implementation-step",
|
|
873
|
-
epicContext: input.epicContext
|
|
937
|
+
epicContext: input.epicContext,
|
|
938
|
+
resumeSessionId: input.resumeSessionId,
|
|
939
|
+
onSessionId: input.onSessionId
|
|
874
940
|
}
|
|
875
941
|
}
|
|
876
942
|
);
|
package/src/forge/gitlab.js
CHANGED
|
@@ -150,17 +150,17 @@ export function createGitLabClient(config) {
|
|
|
150
150
|
|
|
151
151
|
async createIssueReaction(repoUrl, issueNumber, content = "eyes") {
|
|
152
152
|
const repo = parseGitLabRepoUrl(repoUrl);
|
|
153
|
-
return
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
153
|
+
return awardEmojiOnce(
|
|
154
|
+
config,
|
|
155
|
+
projectPath(repo, `${issueNotePath(issueNumber)}/award_emoji`),
|
|
156
|
+
`${repo.origin}/`,
|
|
157
|
+
mapReaction(content)
|
|
158
|
+
);
|
|
159
159
|
},
|
|
160
160
|
|
|
161
161
|
async createIssueCommentReaction(repoUrl, commentId, content = "eyes", issueNumber = "") {
|
|
162
162
|
const repo = parseGitLabRepoUrl(repoUrl);
|
|
163
|
-
const
|
|
163
|
+
const name = mapReaction(content);
|
|
164
164
|
const candidates = [
|
|
165
165
|
projectPath(repo, `${mergeRequestNotePath(issueNumber)}/${commentId}/award_emoji`),
|
|
166
166
|
projectPath(repo, `/issues/${issueNumber}/notes/${commentId}/award_emoji`)
|
|
@@ -169,15 +169,9 @@ export function createGitLabClient(config) {
|
|
|
169
169
|
let lastError = null;
|
|
170
170
|
for (const candidate of candidates) {
|
|
171
171
|
try {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
method: "POST",
|
|
175
|
-
body
|
|
176
|
-
});
|
|
172
|
+
// Don't swallow 404 here: a wrong-kind path must fall through to the next candidate.
|
|
173
|
+
return await awardEmojiOnce(config, candidate, `${repo.origin}/`, name, { ignoreMissing: false });
|
|
177
174
|
} catch (error) {
|
|
178
|
-
if (String(error?.message || "").includes("Award Emoji Name has already been taken")) {
|
|
179
|
-
return;
|
|
180
|
-
}
|
|
181
175
|
lastError = error;
|
|
182
176
|
}
|
|
183
177
|
}
|
|
@@ -187,13 +181,12 @@ export function createGitLabClient(config) {
|
|
|
187
181
|
|
|
188
182
|
async createPullRequestCommentReaction(repoUrl, commentId, content = "eyes", pullNumber = "") {
|
|
189
183
|
const repo = parseGitLabRepoUrl(repoUrl);
|
|
190
|
-
return
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
});
|
|
184
|
+
return awardEmojiOnce(
|
|
185
|
+
config,
|
|
186
|
+
projectPath(repo, `${mergeRequestNotePath(pullNumber)}/${commentId}/award_emoji`),
|
|
187
|
+
`${repo.origin}/`,
|
|
188
|
+
mapReaction(content)
|
|
189
|
+
);
|
|
197
190
|
},
|
|
198
191
|
|
|
199
192
|
async isRepoCollaborator(repoUrl, username) {
|
|
@@ -544,11 +537,12 @@ export function createGitLabClient(config) {
|
|
|
544
537
|
|
|
545
538
|
async addReactionToNote(repoUrl, prNumber, noteId, reaction = "eyes") {
|
|
546
539
|
const repo = parseGitLabRepoUrl(repoUrl);
|
|
547
|
-
return
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
540
|
+
return awardEmojiOnce(
|
|
541
|
+
config,
|
|
542
|
+
projectPath(repo, `/merge_requests/${prNumber}/notes/${noteId}/award_emoji`),
|
|
543
|
+
`${repo.origin}/`,
|
|
544
|
+
mapReaction(reaction)
|
|
545
|
+
);
|
|
552
546
|
},
|
|
553
547
|
|
|
554
548
|
async listAiReviewedPrs(repoUrl) {
|
|
@@ -1014,6 +1008,47 @@ function mapReaction(content) {
|
|
|
1014
1008
|
}
|
|
1015
1009
|
}
|
|
1016
1010
|
|
|
1011
|
+
async function findExistingAward(config, awardEmojiPath, baseUrl, name) {
|
|
1012
|
+
// GitLab returns every award on the resource; reuse ours instead of re-POSTing each poll.
|
|
1013
|
+
try {
|
|
1014
|
+
const existing = await requestGitLab(config, awardEmojiPath, { baseUrl });
|
|
1015
|
+
if (Array.isArray(existing)) {
|
|
1016
|
+
return existing.find((emoji) => emoji?.name === name) || null;
|
|
1017
|
+
}
|
|
1018
|
+
} catch {
|
|
1019
|
+
// List failed — fall through to POST, which still tolerates a duplicate.
|
|
1020
|
+
}
|
|
1021
|
+
return null;
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
function isAwardEmojiDuplicate(error) {
|
|
1025
|
+
return String(error?.message || "").includes("has already been taken");
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function isMissingResource(error) {
|
|
1029
|
+
return error?.statusCode === 404 || /failed with 404\b/u.test(String(error?.message || ""));
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Idempotent award: skip when ours already exists, swallow the duplicate race,
|
|
1033
|
+
// and (for single-path callers) treat a deleted resource as a no-op success.
|
|
1034
|
+
async function awardEmojiOnce(config, awardEmojiPath, baseUrl, name, { ignoreMissing = true } = {}) {
|
|
1035
|
+
const found = await findExistingAward(config, awardEmojiPath, baseUrl, name);
|
|
1036
|
+
if (found) {
|
|
1037
|
+
return found;
|
|
1038
|
+
}
|
|
1039
|
+
try {
|
|
1040
|
+
return await requestGitLab(config, awardEmojiPath, { baseUrl, method: "POST", body: { name } });
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
if (isAwardEmojiDuplicate(error)) {
|
|
1043
|
+
return {};
|
|
1044
|
+
}
|
|
1045
|
+
if (ignoreMissing && isMissingResource(error)) {
|
|
1046
|
+
return {};
|
|
1047
|
+
}
|
|
1048
|
+
throw error;
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1017
1052
|
async function requestGitLab(config, pathOrUrl, init = {}) {
|
|
1018
1053
|
const url = pathOrUrl.startsWith("http")
|
|
1019
1054
|
? new URL(pathOrUrl)
|
|
@@ -1050,7 +1085,9 @@ async function requestGitLab(config, pathOrUrl, init = {}) {
|
|
|
1050
1085
|
authError.statusCode = response.status;
|
|
1051
1086
|
throw authError;
|
|
1052
1087
|
}
|
|
1053
|
-
|
|
1088
|
+
const requestError = new Error(buildGitLabRequestError(response.status, url, body));
|
|
1089
|
+
requestError.statusCode = response.status;
|
|
1090
|
+
throw requestError;
|
|
1054
1091
|
}
|
|
1055
1092
|
|
|
1056
1093
|
if (response.status === 204) {
|
package/src/forge/repo-host.js
CHANGED
|
@@ -2,6 +2,22 @@ import process from "node:process";
|
|
|
2
2
|
|
|
3
3
|
const GITHUB_HOST = "github.com";
|
|
4
4
|
|
|
5
|
+
// Path segments that mark a URL as something other than a repo root: forge
|
|
6
|
+
// sub-pages (blob/tree/pull/merge_requests/…) and auth/account endpoints
|
|
7
|
+
// (sign_in/users/…). Matched as a whole path component (slash-delimited).
|
|
8
|
+
const NON_REPO_PATHS =
|
|
9
|
+
/\/(?:blob|tree|raw|actions|issues|pull|commit|releases|compare|discussions|wiki|tags|branches|security|pulse|graphs|settings|merge_requests|pipelines|environments|packages|network|activity|sign_in|sign_up|users|sessions|oauth|-)\//u;
|
|
10
|
+
// GitLab uses /-/ as a separator before sub-paths (e.g. /-/merge_requests/12).
|
|
11
|
+
const GITLAB_SUBPATH = /\/-\//u;
|
|
12
|
+
|
|
13
|
+
// True when a URL path is NOT a plain `owner/repo` root. Used both to filter
|
|
14
|
+
// discovered URLs and (authoritatively) inside parseRepoParts so a junk URL
|
|
15
|
+
// can never be turned into a clone target.
|
|
16
|
+
export function isNonRepoPath(pathOrUrl) {
|
|
17
|
+
const probe = `/${String(pathOrUrl || "").replace(/^\/+/u, "")}/`;
|
|
18
|
+
return NON_REPO_PATHS.test(probe) || GITLAB_SUBPATH.test(probe);
|
|
19
|
+
}
|
|
20
|
+
|
|
5
21
|
export function parseRepoUrl(repoUrl) {
|
|
6
22
|
const value = String(repoUrl || "").trim();
|
|
7
23
|
if (!value) {
|
|
@@ -101,12 +117,8 @@ export function extractRepoUrls(text) {
|
|
|
101
117
|
return [];
|
|
102
118
|
}
|
|
103
119
|
|
|
104
|
-
// Filter out URLs that are not repo roots (
|
|
105
|
-
const
|
|
106
|
-
/\/(?:blob|tree|raw|actions|issues|pull|commit|releases|compare|discussions|wiki|tags|branches|security|pulse|graphs|settings|merge_requests|pipelines|environments|packages|network|activity)\//u;
|
|
107
|
-
// GitLab uses /-/ as a separator before sub-paths (e.g. /-/merge_requests/12, /-/issues/5)
|
|
108
|
-
const GITLAB_SUBPATH = /\/-\//u;
|
|
109
|
-
const repoUrls = matches.filter((url) => !NON_REPO_PATHS.test(url) && !GITLAB_SUBPATH.test(url));
|
|
120
|
+
// Filter out URLs that are not repo roots (blob/tree/issues/merge_requests/…)
|
|
121
|
+
const repoUrls = matches.filter((url) => !isNonRepoPath(url));
|
|
110
122
|
|
|
111
123
|
return [...new Set(repoUrls)];
|
|
112
124
|
}
|
|
@@ -116,6 +128,14 @@ function parseRepoParts(host, rawPath) {
|
|
|
116
128
|
const cleanPath = String(rawPath || "")
|
|
117
129
|
.replace(/\.git$/u, "")
|
|
118
130
|
.replace(/\/+$/u, "");
|
|
131
|
+
|
|
132
|
+
// Single source of truth: reject MR/issue/auth/redirect paths here so a junk
|
|
133
|
+
// URL reaching parseRepoUrl by any route can never be built into a bogus
|
|
134
|
+
// clone target (e.g. `.../-/merge_requests/15.git`, `.../users/sign_in.git`).
|
|
135
|
+
if (isNonRepoPath(cleanPath)) {
|
|
136
|
+
throw new Error(`Not a ${getProviderLabel(provider)} repo URL (sub-page or auth path): ${rawPath}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
119
139
|
const segments = cleanPath.split("/").filter(Boolean);
|
|
120
140
|
|
|
121
141
|
if (segments.length < 2) {
|
package/src/fs-atomic.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { rename, writeFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
// Monotonic per-process counter so two writes from the same process never
|
|
4
|
+
// share a temp path, even within the same millisecond.
|
|
5
|
+
let tempCounter = 0;
|
|
6
|
+
|
|
7
|
+
// Build a temp path that is unique per process + call. Concurrent writers
|
|
8
|
+
// (same process or two worker processes) each rename their own temp file onto
|
|
9
|
+
// the target, so the rename can never hit ENOENT from another writer having
|
|
10
|
+
// already consumed a shared `<file>.tmp`.
|
|
11
|
+
export function buildTempPath(filePath) {
|
|
12
|
+
tempCounter = (tempCounter + 1) % Number.MAX_SAFE_INTEGER;
|
|
13
|
+
return `${filePath}.${process.pid}.${tempCounter}.tmp`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Atomically write `content` to `filePath`: write to a unique temp file then
|
|
17
|
+
// rename onto the target. The rename is atomic on POSIX, so readers always see
|
|
18
|
+
// either the old or the new complete file, never a partial write.
|
|
19
|
+
export async function atomicWriteFile(filePath, content) {
|
|
20
|
+
const tempFile = buildTempPath(filePath);
|
|
21
|
+
await writeFile(tempFile, content, "utf8");
|
|
22
|
+
await rename(tempFile, filePath);
|
|
23
|
+
}
|
package/src/logger.js
CHANGED
|
@@ -1,17 +1,52 @@
|
|
|
1
|
-
import { appendFile, mkdir } from "node:fs/promises";
|
|
1
|
+
import { appendFile, mkdir, rename, rm, stat } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
+
const LEVELS = { DEBUG: 10, INFO: 20, WARN: 30, ERROR: 40 };
|
|
5
|
+
const DEFAULT_MAX_BYTES = 50 * 1024 * 1024; // 50MB per worker log file before rotation.
|
|
6
|
+
const DEFAULT_MAX_FILES = 5;
|
|
7
|
+
|
|
8
|
+
// Serialize writes + rotation per log file so concurrent log calls can't
|
|
9
|
+
// interleave a rotation rename with an append (which produced lost/garbled lines).
|
|
10
|
+
const writeChains = new Map();
|
|
11
|
+
|
|
4
12
|
export function getIssueLogFile(workerLogFile, issueKey) {
|
|
5
13
|
return path.join(path.dirname(workerLogFile), "issue-logs", `${issueKey}.log`);
|
|
6
14
|
}
|
|
7
15
|
|
|
16
|
+
function resolveThreshold() {
|
|
17
|
+
const raw = String(process.env.LOG_LEVEL || "INFO").toUpperCase();
|
|
18
|
+
return LEVELS[raw] ?? LEVELS.INFO;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolvePositiveInt(value, fallback) {
|
|
22
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
23
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
24
|
+
}
|
|
25
|
+
|
|
8
26
|
export function createLogger(logFile) {
|
|
27
|
+
const threshold = resolveThreshold();
|
|
28
|
+
const maxBytes = resolvePositiveInt(process.env.WORKER_LOG_MAX_BYTES, DEFAULT_MAX_BYTES);
|
|
29
|
+
const maxFiles = resolvePositiveInt(process.env.WORKER_LOG_MAX_FILES, DEFAULT_MAX_FILES);
|
|
30
|
+
|
|
31
|
+
const write = (level, message, metadata) => {
|
|
32
|
+
if (LEVELS[level] < threshold) {
|
|
33
|
+
return Promise.resolve();
|
|
34
|
+
}
|
|
35
|
+
return enqueue(logFile, () => writeLogLine(logFile, level, message, metadata, maxBytes, maxFiles));
|
|
36
|
+
};
|
|
37
|
+
|
|
9
38
|
const base = {
|
|
39
|
+
debug(message, metadata) {
|
|
40
|
+
return write("DEBUG", message, metadata);
|
|
41
|
+
},
|
|
10
42
|
info(message, metadata) {
|
|
11
|
-
return
|
|
43
|
+
return write("INFO", message, metadata);
|
|
44
|
+
},
|
|
45
|
+
warn(message, metadata) {
|
|
46
|
+
return write("WARN", message, metadata);
|
|
12
47
|
},
|
|
13
48
|
error(message, metadata) {
|
|
14
|
-
return
|
|
49
|
+
return write("ERROR", message, metadata);
|
|
15
50
|
},
|
|
16
51
|
issueLog(issueKey, chunk, phase) {
|
|
17
52
|
const issueLogFile = getIssueLogFile(logFile, issueKey);
|
|
@@ -19,7 +54,9 @@ export function createLogger(logFile) {
|
|
|
19
54
|
},
|
|
20
55
|
withTag(tag) {
|
|
21
56
|
return {
|
|
57
|
+
debug: (message, metadata) => base.debug(`[${tag}] ${message}`, metadata),
|
|
22
58
|
info: (message, metadata) => base.info(`[${tag}] ${message}`, metadata),
|
|
59
|
+
warn: (message, metadata) => base.warn(`[${tag}] ${message}`, metadata),
|
|
23
60
|
error: (message, metadata) => base.error(`[${tag}] ${message}`, metadata),
|
|
24
61
|
issueLog: (issueKey, chunk, phase) => base.issueLog(issueKey, chunk, phase),
|
|
25
62
|
withTag: (subTag) => base.withTag(`${tag}:${subTag}`)
|
|
@@ -29,6 +66,53 @@ export function createLogger(logFile) {
|
|
|
29
66
|
return base;
|
|
30
67
|
}
|
|
31
68
|
|
|
69
|
+
function enqueue(logFile, task) {
|
|
70
|
+
const prev = writeChains.get(logFile) || Promise.resolve();
|
|
71
|
+
// Run the task whether the previous one resolved or rejected; never let a
|
|
72
|
+
// prior logging failure block subsequent log lines.
|
|
73
|
+
const next = prev.then(task, task);
|
|
74
|
+
const tracked = next
|
|
75
|
+
.catch(() => {})
|
|
76
|
+
.finally(() => {
|
|
77
|
+
if (writeChains.get(logFile) === tracked) {
|
|
78
|
+
writeChains.delete(logFile);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
writeChains.set(logFile, tracked);
|
|
82
|
+
return next;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function rotateIfNeeded(logFile, maxBytes, maxFiles) {
|
|
86
|
+
let size = 0;
|
|
87
|
+
try {
|
|
88
|
+
size = (await stat(logFile)).size;
|
|
89
|
+
} catch {
|
|
90
|
+
return; // No file yet (or unstattable) — nothing to rotate.
|
|
91
|
+
}
|
|
92
|
+
if (size < maxBytes) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Shift archives: .(N-1) -> .N (overwriting the oldest), ..., .1 -> .2, then live -> .1.
|
|
97
|
+
for (let i = maxFiles - 1; i >= 1; i--) {
|
|
98
|
+
try {
|
|
99
|
+
await rename(`${logFile}.${i}`, `${logFile}.${i + 1}`);
|
|
100
|
+
} catch {
|
|
101
|
+
// Archive slot may not exist yet; skip.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
await rename(logFile, `${logFile}.1`);
|
|
106
|
+
} catch {
|
|
107
|
+
// Live file vanished between stat and rename — next append recreates it.
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
await rm(`${logFile}.${maxFiles + 1}`, { force: true });
|
|
111
|
+
} catch {
|
|
112
|
+
// Best-effort cleanup of an over-the-limit archive.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
32
116
|
async function writeIssueChunk(issueLogFile, chunk, phase) {
|
|
33
117
|
await mkdir(path.dirname(issueLogFile), { recursive: true });
|
|
34
118
|
const timestamp = new Date().toISOString();
|
|
@@ -43,7 +127,11 @@ async function writeIssueChunk(issueLogFile, chunk, phase) {
|
|
|
43
127
|
}
|
|
44
128
|
}
|
|
45
129
|
|
|
46
|
-
async function writeLogLine(logFile, level, message, metadata) {
|
|
130
|
+
async function writeLogLine(logFile, level, message, metadata, maxBytes, maxFiles) {
|
|
131
|
+
if (!logFile) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
await rotateIfNeeded(logFile, maxBytes, maxFiles);
|
|
47
135
|
const timestamp = new Date().toISOString();
|
|
48
136
|
const payload = metadata === undefined ? "" : formatMetadata(metadata);
|
|
49
137
|
|
package/src/memory.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { access, mkdir, readdir, readFile,
|
|
1
|
+
import { access, mkdir, readdir, readFile, unlink } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { extractRepoUrls as extractForgeRepoUrls } from "./forge/repo-host.js";
|
|
4
|
+
import { atomicWriteFile } from "./fs-atomic.js";
|
|
4
5
|
|
|
5
6
|
const MEMORY_DIR_NAME = "memory";
|
|
6
7
|
const LEGACY_JSON_BLOCK_PATTERN = /```json\s*([\s\S]*?)\s*```/u;
|
|
@@ -77,9 +78,7 @@ export async function saveEpicMemory(filePath, issue, updates) {
|
|
|
77
78
|
const output = JSON.stringify(nextData, null, 2);
|
|
78
79
|
const directory = path.dirname(filePath);
|
|
79
80
|
await mkdir(directory, { recursive: true });
|
|
80
|
-
|
|
81
|
-
await writeFile(tempFile, output, "utf8");
|
|
82
|
-
await rename(tempFile, filePath);
|
|
81
|
+
await atomicWriteFile(filePath, output);
|
|
83
82
|
_knownReposCacheByRoot.clear();
|
|
84
83
|
return nextData;
|
|
85
84
|
}
|
|
@@ -98,9 +97,7 @@ export async function saveIssueMemory(filePath, issue, updates) {
|
|
|
98
97
|
const output = JSON.stringify(nextData, null, 2);
|
|
99
98
|
const directory = path.dirname(filePath);
|
|
100
99
|
await mkdir(directory, { recursive: true });
|
|
101
|
-
|
|
102
|
-
await writeFile(tempFile, output, "utf8");
|
|
103
|
-
await rename(tempFile, filePath);
|
|
100
|
+
await atomicWriteFile(filePath, output);
|
|
104
101
|
_issueMemoryCacheByRoot.clear();
|
|
105
102
|
return nextData;
|
|
106
103
|
}
|
|
@@ -147,9 +144,7 @@ async function loadMemoryFile(filePath, fallbackData, normalize) {
|
|
|
147
144
|
};
|
|
148
145
|
const directory = path.dirname(filePath);
|
|
149
146
|
await mkdir(directory, { recursive: true });
|
|
150
|
-
|
|
151
|
-
await writeFile(tempFile, JSON.stringify(mergedData, null, 2), "utf8");
|
|
152
|
-
await rename(tempFile, filePath);
|
|
147
|
+
await atomicWriteFile(filePath, JSON.stringify(mergedData, null, 2));
|
|
153
148
|
await unlink(mdPath);
|
|
154
149
|
return {
|
|
155
150
|
filePath,
|
package/src/repo.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
-
import { access, mkdir, rm } from "node:fs/promises";
|
|
2
|
+
import { access, mkdir, rename, rm } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { buildGitEnvForRepoUrl, parseGitHubRepoUrl, parseRepoUrl } from "./forge/repo-host.js";
|
|
@@ -582,14 +582,51 @@ export async function hasUsableGitCheckout(repoPath) {
|
|
|
582
582
|
return isUsableGitCheckout(repoPath);
|
|
583
583
|
}
|
|
584
584
|
|
|
585
|
+
/**
|
|
586
|
+
* Quarantine a checkout we cannot delete by renaming it aside. Renaming only
|
|
587
|
+
* needs write permission on the *parent* directory, not ownership of the
|
|
588
|
+
* (root-owned) contents — so this succeeds in the multi-user case where `rm`
|
|
589
|
+
* and `chmod` fail with EACCES. Once moved out of the way, the caller can
|
|
590
|
+
* clone a fresh checkout into the original path and the worker self-recovers
|
|
591
|
+
* instead of churning the same issue forever.
|
|
592
|
+
*
|
|
593
|
+
* Returns the quarantine path on success, or null if even the rename failed
|
|
594
|
+
* (e.g. the parent directory itself is not writable).
|
|
595
|
+
*/
|
|
596
|
+
export async function quarantineCheckout(targetPath) {
|
|
597
|
+
const parent = path.dirname(targetPath);
|
|
598
|
+
const base = path.basename(targetPath);
|
|
599
|
+
// Stable, idempotent suffix sequence so repeated runs don't accumulate
|
|
600
|
+
// unbounded copies: reuse a `.broken` slot, trying to clear a previous one
|
|
601
|
+
// first, then fall back to numbered slots.
|
|
602
|
+
for (let i = 0; i < 50; i += 1) {
|
|
603
|
+
const suffix = i === 0 ? ".broken" : `.broken.${i}`;
|
|
604
|
+
const quarantinePath = path.join(parent, `${base}${suffix}`);
|
|
605
|
+
if (await pathExists(quarantinePath)) {
|
|
606
|
+
// Try to reclaim the slot; if it is also undeletable, move to the next.
|
|
607
|
+
await rm(quarantinePath, { recursive: true, force: true }).catch(() => {});
|
|
608
|
+
if (await pathExists(quarantinePath)) {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
await rename(targetPath, quarantinePath);
|
|
614
|
+
return quarantinePath;
|
|
615
|
+
} catch {
|
|
616
|
+
// Parent likely not writable, or a race recreated the slot — try next.
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
|
|
585
622
|
/**
|
|
586
623
|
* Forcefully remove a local checkout directory. Silently ignoring removal
|
|
587
624
|
* failures used to leave the worker wedged in a loop whenever the directory
|
|
588
625
|
* contained files owned by another user (for example, skills synced into the
|
|
589
|
-
* checkout by a process running as root).
|
|
590
|
-
*
|
|
591
|
-
*
|
|
592
|
-
*
|
|
626
|
+
* checkout by a process running as root). We attempt to relax permissions and
|
|
627
|
+
* retry; if that still fails we quarantine the directory aside so a fresh
|
|
628
|
+
* clone can proceed. Only when even the quarantine fails do we throw an
|
|
629
|
+
* actionable error so the operator sees the real blocker.
|
|
593
630
|
*/
|
|
594
631
|
async function forceRemoveCheckout(targetPath) {
|
|
595
632
|
if (!targetPath) {
|
|
@@ -617,13 +654,22 @@ async function forceRemoveCheckout(targetPath) {
|
|
|
617
654
|
|
|
618
655
|
try {
|
|
619
656
|
await rm(targetPath, { recursive: true, force: true });
|
|
657
|
+
return;
|
|
620
658
|
} catch (retryErr) {
|
|
659
|
+
// Multi-user fallback: move the undeletable checkout aside so work can
|
|
660
|
+
// continue. This unblocks the worker without sudo.
|
|
661
|
+
const quarantinePath = await quarantineCheckout(targetPath);
|
|
662
|
+
if (quarantinePath) {
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
621
666
|
const reason = retryErr && retryErr.message ? retryErr.message : String(retryErr);
|
|
622
667
|
throw new Error(
|
|
623
668
|
`Failed to remove stale repository checkout at ${targetPath}. ` +
|
|
624
|
-
"The directory
|
|
625
|
-
|
|
626
|
-
`
|
|
669
|
+
"The directory contains files owned by a different user and its parent " +
|
|
670
|
+
"directory is not writable, so it could not be removed or quarantined. " +
|
|
671
|
+
`It must be cleaned up manually (e.g. \`sudo rm -rf ${targetPath}\`) ` +
|
|
672
|
+
`before the worker can recover. Underlying error: ${reason}`
|
|
627
673
|
);
|
|
628
674
|
}
|
|
629
675
|
}
|
|
@@ -687,6 +733,36 @@ async function execGitOutput(repoPath, args) {
|
|
|
687
733
|
}
|
|
688
734
|
}
|
|
689
735
|
|
|
736
|
+
/**
|
|
737
|
+
* Return a truncated unified diff of the current branch against its base, so a
|
|
738
|
+
* re-invoked implementation run can see what earlier runs already committed and
|
|
739
|
+
* build on it instead of losing that context. Returns "" when there is no diff
|
|
740
|
+
* or the diff cannot be produced (best-effort — never throws).
|
|
741
|
+
*
|
|
742
|
+
* @param {string} repoPath
|
|
743
|
+
* @param {string} baseBranch base to diff against (e.g. "main", "develop")
|
|
744
|
+
* @param {number} maxChars hard cap so the diff cannot blow the prompt budget
|
|
745
|
+
*/
|
|
746
|
+
export async function getBranchDiffAgainstBase(repoPath, baseBranch, maxChars = 12_000) {
|
|
747
|
+
const base = String(baseBranch || "").trim() || (await resolveRemoteDefaultBranch(repoPath));
|
|
748
|
+
try {
|
|
749
|
+
// Use the merge-base (`...`) form so only this branch's own changes show,
|
|
750
|
+
// not commits that merely landed on the base since the branch started.
|
|
751
|
+
const diff = await execGitOutput(repoPath, ["diff", "--stat=200", `origin/${base}...HEAD`]);
|
|
752
|
+
const patch = await execGitOutput(repoPath, ["diff", `origin/${base}...HEAD`]);
|
|
753
|
+
const combined = `${diff.trim()}\n\n${patch.trim()}`.trim();
|
|
754
|
+
if (!combined) {
|
|
755
|
+
return "";
|
|
756
|
+
}
|
|
757
|
+
if (combined.length <= maxChars) {
|
|
758
|
+
return combined;
|
|
759
|
+
}
|
|
760
|
+
return `${combined.slice(0, maxChars)}\n... [diff truncated at ${maxChars} chars]`;
|
|
761
|
+
} catch {
|
|
762
|
+
return "";
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
690
766
|
export async function branchHasChangesAgainstMain(repoPath, baseRef) {
|
|
691
767
|
const branch = baseRef || (await resolveRemoteDefaultBranch(repoPath));
|
|
692
768
|
const output = await execGitOutput(repoPath, ["rev-list", "--count", `origin/${branch}..HEAD`]);
|
package/src/runtime.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { mkdir, readFile,
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { atomicWriteFile } from "./fs-atomic.js";
|
|
3
4
|
|
|
4
5
|
export const RUNTIME_DIR_NAME = ".claude-teammate";
|
|
5
6
|
const stateWriteQueues = new Map();
|
|
@@ -119,9 +120,7 @@ async function startStateWrite(stateFile, state) {
|
|
|
119
120
|
let queuedWrite;
|
|
120
121
|
queuedWrite = (async () => {
|
|
121
122
|
await mkdir(path.dirname(stateFile), { recursive: true });
|
|
122
|
-
|
|
123
|
-
await writeFile(tempFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
124
|
-
await rename(tempFile, stateFile);
|
|
123
|
+
await atomicWriteFile(stateFile, `${JSON.stringify(state, null, 2)}\n`);
|
|
125
124
|
})().finally(() => {
|
|
126
125
|
if (stateWriteQueues.get(stateFile) === queuedWrite) {
|
|
127
126
|
stateWriteQueues.delete(stateFile);
|
package/src/worker/forge-sync.js
CHANGED
|
@@ -406,13 +406,19 @@ export async function transitionLinkedJiraIssueToReview(pullRequestTitle, jira,
|
|
|
406
406
|
moved: transition.transitioned ? "yes" : "no"
|
|
407
407
|
});
|
|
408
408
|
} catch (error) {
|
|
409
|
+
// Transitioning Jira is a secondary sync step; it must never fail an
|
|
410
|
+
// already-completed PR implementation. Log and continue on any error.
|
|
409
411
|
if (error.status === 404) {
|
|
410
412
|
await logger.warn("Linked Jira issue not found, skipping transition to In Review", {
|
|
411
413
|
issue: jiraKey,
|
|
412
414
|
pr: pullRequestNumber
|
|
413
415
|
});
|
|
414
416
|
} else {
|
|
415
|
-
|
|
417
|
+
await logger.error("Failed to transition linked Jira issue to In Review; continuing", {
|
|
418
|
+
issue: jiraKey,
|
|
419
|
+
pr: pullRequestNumber,
|
|
420
|
+
error
|
|
421
|
+
});
|
|
416
422
|
}
|
|
417
423
|
}
|
|
418
424
|
}
|
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
checkCommentAuthorIsMember,
|
|
29
29
|
hasEyesReaction,
|
|
30
30
|
hasPlusOneReaction,
|
|
31
|
-
isApprovalComment
|
|
31
|
+
isApprovalComment,
|
|
32
|
+
parseRequestedBranch
|
|
32
33
|
} from "./pull-request.js";
|
|
33
34
|
import { buildGitHubIssueState } from "./state-builders.js";
|
|
34
35
|
import { buildIssueRefFromContext, parseOptionalInt, summarizeForLog } from "./utils.js";
|
|
@@ -210,9 +211,21 @@ export async function processGitHubIssue({
|
|
|
210
211
|
const approvalComment = isApprovalComment(latestComment.body);
|
|
211
212
|
|
|
212
213
|
if (approvalComment) {
|
|
213
|
-
|
|
214
|
+
// Branch selection priority: (1) a branch already persisted in memory,
|
|
215
|
+
// (2) a branch the developer named explicitly in the description or the
|
|
216
|
+
// approval comment, (3) an LLM-slug fallback. Honoring (2) stops the bot
|
|
217
|
+
// from ignoring "dùng nhánh feature/x" and inventing an unstable slug.
|
|
218
|
+
const requestedBranch = githubIssueMemory.branch_name
|
|
219
|
+
? { branch: "", base: "" }
|
|
220
|
+
: parseRequestedBranch(`${detail.body || ""}\n${latestComment.body || ""}`);
|
|
221
|
+
const englishSlug =
|
|
222
|
+
githubIssueMemory.branch_name || requestedBranch.branch
|
|
223
|
+
? ""
|
|
224
|
+
: await runClaudeBranchSlug(detail.title, projectRoot);
|
|
214
225
|
const nextBranchName =
|
|
215
|
-
githubIssueMemory.branch_name ||
|
|
226
|
+
githubIssueMemory.branch_name ||
|
|
227
|
+
requestedBranch.branch ||
|
|
228
|
+
buildImplementationBranchName(detail.title, detail.number, englishSlug);
|
|
216
229
|
const existingPr = await github.findPullRequestByHead(githubIssueMemory.repo_url, nextBranchName);
|
|
217
230
|
if (!existingPr) {
|
|
218
231
|
githubIssueMemory.pr_url = "";
|
|
@@ -260,7 +273,13 @@ export async function processGitHubIssue({
|
|
|
260
273
|
}
|
|
261
274
|
|
|
262
275
|
const repoEpicEntry = (approvalEpicMemory?.repos || []).find((r) => r.url === githubIssueMemory.repo_url);
|
|
263
|
-
|
|
276
|
+
// A base/target branch the developer named explicitly (e.g. "base lên
|
|
277
|
+
// develop") wins over the epic's working branch and the repo default, so
|
|
278
|
+
// the PR is opened against the branch they actually asked for.
|
|
279
|
+
const baseBranch =
|
|
280
|
+
requestedBranch.base ||
|
|
281
|
+
repoEpicEntry?.working_branch ||
|
|
282
|
+
(await github.getDefaultBranch(githubIssueMemory.repo_url));
|
|
264
283
|
await services.pullLatest(githubIssueMemory.local_path);
|
|
265
284
|
|
|
266
285
|
// Use a short-lived worktree to create the branch so the primary clone
|
|
@@ -75,7 +75,7 @@ export function createDraftPullRequestPoller({
|
|
|
75
75
|
|
|
76
76
|
const latestComment = getLatestGitHubComment(detail.comments);
|
|
77
77
|
if (!latestComment || isForgeBotAuthor(latestComment.author, botUser)) {
|
|
78
|
-
await logger.
|
|
78
|
+
await logger.debug("Draft PR has no actionable human comment, not queueing for work", {
|
|
79
79
|
pr: pullRequest.number,
|
|
80
80
|
repo: pullRequest.repoUrl
|
|
81
81
|
});
|
|
@@ -70,7 +70,7 @@ export function createTrackedIssuePoller({
|
|
|
70
70
|
const detail = await provider.fetchIssue(repo.url, issue.number);
|
|
71
71
|
const latestComment = getLatestGitHubComment(detail.comments);
|
|
72
72
|
if (!latestComment || isForgeBotAuthor(latestComment.author, botUser)) {
|
|
73
|
-
await logger.
|
|
73
|
+
await logger.debug("GitHub issue has no actionable human comment, not queueing for work", {
|
|
74
74
|
issue: issue.number,
|
|
75
75
|
repo: issue.repoUrl
|
|
76
76
|
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
branchHasChangesAgainstMain,
|
|
10
10
|
commitAndPushRepoChanges,
|
|
11
11
|
ensureWorktreeForBranch,
|
|
12
|
+
getBranchDiffAgainstBase,
|
|
12
13
|
prepareRepoForBranch,
|
|
13
14
|
removeWorktree
|
|
14
15
|
} from "../repo.js";
|
|
@@ -88,6 +89,7 @@ export function createPullRequestImplementationServices(overrides = {}) {
|
|
|
88
89
|
clearForgePullRequestBlocked,
|
|
89
90
|
ensureWorktreeForBranch,
|
|
90
91
|
removeWorktree,
|
|
92
|
+
getBranchDiffAgainstBase,
|
|
91
93
|
commitPartialPullRequestChanges: (repo, detail, logger) => commitPartialPullRequestChanges(repo, detail, logger),
|
|
92
94
|
...overrides
|
|
93
95
|
};
|
|
@@ -178,6 +180,14 @@ export async function processPullRequestImplementation({
|
|
|
178
180
|
const issueKey = githubIssueMemory?.jira_key || `PR-${detail.number}`;
|
|
179
181
|
const epicSnapshot = buildEpicMemoryPromptSnapshot(epicMemory);
|
|
180
182
|
|
|
183
|
+
// Re-ground the run on any work earlier runs already committed to this
|
|
184
|
+
// branch. Without this, a re-invocation triggered by a PR comment loses the
|
|
185
|
+
// context of what was done and re-implements or reverts prior changes.
|
|
186
|
+
const branchDiff =
|
|
187
|
+
typeof services.getBranchDiffAgainstBase === "function"
|
|
188
|
+
? await services.getBranchDiffAgainstBase(repoPath, detail.baseRef)
|
|
189
|
+
: "";
|
|
190
|
+
|
|
181
191
|
// Proactively break down large PRs before attempting full implementation
|
|
182
192
|
if (!inStepMode && nextBody.length > 5000) {
|
|
183
193
|
try {
|
|
@@ -248,6 +258,7 @@ export async function processPullRequestImplementation({
|
|
|
248
258
|
step: stepText,
|
|
249
259
|
memory: { epic: epicSnapshot },
|
|
250
260
|
epicContext: epicMemory,
|
|
261
|
+
branchDiff,
|
|
251
262
|
repoPath,
|
|
252
263
|
repoPaths: repoAccess.repoPaths,
|
|
253
264
|
branchName: detail.headRef,
|
|
@@ -296,6 +307,7 @@ export async function processPullRequestImplementation({
|
|
|
296
307
|
memory: { epic: epicSnapshot },
|
|
297
308
|
epicContext: epicMemory,
|
|
298
309
|
latestComment,
|
|
310
|
+
branchDiff,
|
|
299
311
|
repoPath,
|
|
300
312
|
repoPaths: repoAccess.repoPaths,
|
|
301
313
|
branchName: detail.headRef,
|
|
@@ -310,6 +310,66 @@ export function buildImplementationBranchName(title, issueNumber, englishSlug =
|
|
|
310
310
|
return `${type}/${id}${suffix}`;
|
|
311
311
|
}
|
|
312
312
|
|
|
313
|
+
// A git ref a human would actually type: optional type prefix then a path.
|
|
314
|
+
// Deliberately strict so prose around the keyword ("the develop environment")
|
|
315
|
+
// does not get mistaken for a branch request.
|
|
316
|
+
const BRANCH_TOKEN = "[A-Za-z0-9]/(?:[A-Za-z0-9._-]+/?)*[A-Za-z0-9._-]|[A-Za-z][A-Za-z0-9._/-]*[A-Za-z0-9]";
|
|
317
|
+
// Filler words that can sit between the keyword and the ref ("base it on X",
|
|
318
|
+
// "base lên X", "checkout the X branch") — consumed so they are not captured
|
|
319
|
+
// as the branch name.
|
|
320
|
+
const BRANCH_CONNECTOR = "(?:branch\\s+)?(?:it\\s+)?(?:the\\s+)?(?:on(?:to)?|off(?:\\s+of)?|lên|vào|từ)?";
|
|
321
|
+
// Single-word tokens that are never a real branch name; if the capture lands on
|
|
322
|
+
// one, the directive was prose, not a request.
|
|
323
|
+
const BRANCH_STOPWORDS = new Set([
|
|
324
|
+
"the",
|
|
325
|
+
"it",
|
|
326
|
+
"this",
|
|
327
|
+
"that",
|
|
328
|
+
"a",
|
|
329
|
+
"an",
|
|
330
|
+
"on",
|
|
331
|
+
"to",
|
|
332
|
+
"of",
|
|
333
|
+
"your",
|
|
334
|
+
"my",
|
|
335
|
+
"our",
|
|
336
|
+
"please",
|
|
337
|
+
"and",
|
|
338
|
+
"for",
|
|
339
|
+
"branch",
|
|
340
|
+
"nhánh"
|
|
341
|
+
]);
|
|
342
|
+
|
|
343
|
+
function matchBranchDirective(text, keywords) {
|
|
344
|
+
const source = String(text || "");
|
|
345
|
+
// Accept "branch: x", "branch = x", "use branch `x`", "nhánh x", "base on x",
|
|
346
|
+
// quoted or backticked. Scan all matches and take the first that is an actual
|
|
347
|
+
// ref token (not a stopword), so filler between keyword and ref is skipped.
|
|
348
|
+
const re = new RegExp(`(?:${keywords})\\s*${BRANCH_CONNECTOR}\\s*[:=]?\\s*[\`'"]?(${BRANCH_TOKEN})[\`'"]?`, "giu");
|
|
349
|
+
let m = re.exec(source);
|
|
350
|
+
while (m) {
|
|
351
|
+
const candidate = m[1].trim();
|
|
352
|
+
if (candidate && !BRANCH_STOPWORDS.has(candidate.toLowerCase())) {
|
|
353
|
+
return candidate;
|
|
354
|
+
}
|
|
355
|
+
m = re.exec(source);
|
|
356
|
+
}
|
|
357
|
+
return "";
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Extract a branch and/or base branch the developer explicitly asked for in an
|
|
361
|
+
// issue description / comment. Returns "" for any field not specified. The
|
|
362
|
+
// caller prefers these over an LLM-generated slug so the bot stops ignoring
|
|
363
|
+
// "dùng nhánh feature/x" / "base lên develop".
|
|
364
|
+
export function parseRequestedBranch(text) {
|
|
365
|
+
const branch = matchBranchDirective(text, "branch|nhánh|use\\s+branch|dùng\\s+nhánh|checkout");
|
|
366
|
+
const base = matchBranchDirective(text, "base(?:\\s+branch)?|target(?:\\s+branch)?|base\\s+lên|lên\\s+nhánh");
|
|
367
|
+
return {
|
|
368
|
+
branch: branch && branch !== base ? branch : "",
|
|
369
|
+
base: base || ""
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
313
373
|
function detectBranchType(title) {
|
|
314
374
|
const t = String(title || "");
|
|
315
375
|
|